From 26667d81dbc3b82926230a2df0963d0e1f523104 Mon Sep 17 00:00:00 2001 From: "David S. Batista" Date: Thu, 24 Sep 2026 17:55:18 +0200 Subject: [PATCH 1/4] adding safeguard --- haystack/utils/jinja2_sandbox.py | 23 +++++++++++++++---- test/utils/test_jinja2_sandbox.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/haystack/utils/jinja2_sandbox.py b/haystack/utils/jinja2_sandbox.py index 696351a7642..82219fb0e14 100644 --- a/haystack/utils/jinja2_sandbox.py +++ b/haystack/utils/jinja2_sandbox.py @@ -33,6 +33,16 @@ } ) +# Full module prefixes whose callables must never be invocable from a template + +# Haystack's own data classes, which are in the template context (e.g. `documents`, `messages`) and expose public +# methods that perform file I/O or resolve secrets (`ByteStream.to_file`, `ByteStream.from_file_path`, +# `Document.from_dict`, `Secret.resolve_value`, ...). +# +# Templates only ever need plain attribute access on these objects (`doc.content`, `doc.meta`), never their methods, +# so calls into these modules are denied outright rather than allowlisted method by method. +_UNSAFE_CALLABLE_MODULE_PREFIXES: tuple[str, ...] = ("haystack.dataclasses", "haystack.utils.auth") + 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 :data:`_UNSAFE_MODULE_ROOTS`), and refuses to call any + callable defined in one of Haystack's own data-class modules (see + :data:`_UNSAFE_CALLABLE_MODULE_PREFIXES`). 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,13 @@ 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): + return False return super().is_safe_callable(obj) diff --git a/test/utils/test_jinja2_sandbox.py b/test/utils/test_jinja2_sandbox.py index b289952379c..e6662d8c54b 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, Document +from haystack.utils.auth import Secret from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment @@ -42,3 +44,38 @@ 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_blocks_document_from_dict_gadget(self, tmp_path): + # Document.from_dict()/ByteStream.to_file() let a template write an arbitrary file. + env = HaystackSandboxedEnvironment() + doc = Document(content="hello") + target = tmp_path / "pwned" + template = ( + '{{ doc.from_dict({"content": "x", "blob": {"data": [104, 105], "meta": {}}})' + f'.blob.to_file("{target}") }}}}' + ) + with pytest.raises(jinja2.exceptions.SecurityError): + env.from_string(template).render(doc=doc) + 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) From 5a6ea40518393069498efa6a2bee7d8cae8c39f6 Mon Sep 17 00:00:00 2001 From: "David S. Batista" Date: Thu, 24 Sep 2026 18:01:14 +0200 Subject: [PATCH 2/4] adding release notes --- ...sandbox-dataclass-methods-c9e2843b9e56f49e.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 releasenotes/notes/harden-jinja-sandbox-dataclass-methods-c9e2843b9e56f49e.yaml 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 00000000000..62eee97383b --- /dev/null +++ b/releasenotes/notes/harden-jinja-sandbox-dataclass-methods-c9e2843b9e56f49e.yaml @@ -0,0 +1,14 @@ +--- +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. Calls into ``haystack.dataclasses`` and + ``haystack.utils.auth`` are now denied from templates; plain attribute access (``doc.content``, + ``doc.meta``) is unaffected. From be881ee6782d80d8c98090968ed9ff4499ae85f4 Mon Sep 17 00:00:00 2001 From: "David S. Batista" Date: Fri, 25 Sep 2026 16:15:05 +0200 Subject: [PATCH 3/4] fixing for Windows --- test/utils/test_jinja2_sandbox.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/utils/test_jinja2_sandbox.py b/test/utils/test_jinja2_sandbox.py index e6662d8c54b..5b5d4fdf1ba 100644 --- a/test/utils/test_jinja2_sandbox.py +++ b/test/utils/test_jinja2_sandbox.py @@ -54,15 +54,17 @@ def test_allows_document_attribute_access(self): 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": {}}})' - f'.blob.to_file("{target}") }}}}' + '{{ 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) + env.from_string(template).render(doc=doc, target=str(target)) assert not target.exists() def test_blocks_bytestream_from_file_path_gadget(self): From 67a42cd65314114f6aab71bc6ef623ca18156f4f Mon Sep 17 00:00:00 2001 From: Sebastian Husch Lee Date: Wed, 30 Sep 2026 13:19:58 +0200 Subject: [PATCH 4/4] fix: allow side-effect-free dataclass methods in Jinja sandbox --- haystack/utils/jinja2_sandbox.py | 25 +++++++++++-------- ...ox-dataclass-methods-c9e2843b9e56f49e.yaml | 12 ++++++--- test/utils/test_jinja2_sandbox.py | 11 +++++++- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/haystack/utils/jinja2_sandbox.py b/haystack/utils/jinja2_sandbox.py index 82219fb0e14..7faaeaa576d 100644 --- a/haystack/utils/jinja2_sandbox.py +++ b/haystack/utils/jinja2_sandbox.py @@ -33,16 +33,16 @@ } ) -# Full module prefixes whose callables must never be invocable from a template - -# Haystack's own data classes, which are in the template context (e.g. `documents`, `messages`) and expose public -# methods that perform file I/O or resolve secrets (`ByteStream.to_file`, `ByteStream.from_file_path`, -# `Document.from_dict`, `Secret.resolve_value`, ...). +# Module prefixes whose callables must never be invocable from a template. # -# Templates only ever need plain attribute access on these objects (`doc.content`, `doc.meta`), never their methods, -# so calls into these modules are denied outright rather than allowlisted method by method. +# 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): """ @@ -53,9 +53,9 @@ 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, refuses to call any callable whose defining module is rooted in - a dangerous standard-library module (see :data:`_UNSAFE_MODULE_ROOTS`), and refuses to call any - callable defined in one of Haystack's own data-class modules (see - :data:`_UNSAFE_CALLABLE_MODULE_PREFIXES`). + 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 @@ -77,6 +77,9 @@ def is_safe_callable(self, obj: Any) -> bool: root = module.split(".", 1)[0] if root in _UNSAFE_MODULE_ROOTS: return False - if module.startswith(_UNSAFE_CALLABLE_MODULE_PREFIXES): + 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 index 62eee97383b..83eb0dc5a2b 100644 --- a/releasenotes/notes/harden-jinja-sandbox-dataclass-methods-c9e2843b9e56f49e.yaml +++ b/releasenotes/notes/harden-jinja-sandbox-dataclass-methods-c9e2843b9e56f49e.yaml @@ -9,6 +9,12 @@ security: ``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. Calls into ``haystack.dataclasses`` and - ``haystack.utils.auth`` are now denied from templates; plain attribute access (``doc.content``, - ``doc.meta``) is unaffected. + 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 5b5d4fdf1ba..fb6661d45de 100644 --- a/test/utils/test_jinja2_sandbox.py +++ b/test/utils/test_jinja2_sandbox.py @@ -7,7 +7,7 @@ import jinja2 import pytest -from haystack.dataclasses import ByteStream, Document +from haystack.dataclasses import ByteStream, ChatMessage, Document from haystack.utils.auth import Secret from haystack.utils.jinja2_sandbox import HaystackSandboxedEnvironment @@ -52,6 +52,15 @@ def test_allows_document_attribute_access(self): 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