Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions haystack/utils/jinja2_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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
Expand All @@ -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)
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions test/utils/test_jinja2_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Loading