Skip to content

Commit c4b6ba2

Browse files
authored
fix(core): fix validation for input variables in f-string templates, restrict functionality supported by jinja2, mustache templates (#34035)
* Fix validation for input variables in f-string templates * Restrict functionality of features supported by jinja2 and mustache templates
1 parent b7d1831 commit c4b6ba2

File tree

3 files changed

+272
-19
lines changed

3 files changed

+272
-19
lines changed

libs/core/langchain_core/prompts/string.py

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,66 @@
1919
from collections.abc import Callable, Sequence
2020

2121
try:
22-
from jinja2 import Environment, meta
22+
from jinja2 import meta
23+
from jinja2.exceptions import SecurityError
2324
from jinja2.sandbox import SandboxedEnvironment
2425

26+
class _RestrictedSandboxedEnvironment(SandboxedEnvironment):
27+
"""A more restrictive Jinja2 sandbox that blocks all attribute/method access.
28+
29+
This sandbox only allows simple variable lookups, no attribute or method access.
30+
This prevents template injection attacks via methods like parse_raw().
31+
"""
32+
33+
def is_safe_attribute(self, _obj: Any, _attr: str, _value: Any) -> bool:
34+
"""Block ALL attribute access for security.
35+
36+
Only allow accessing variables directly from the context dict,
37+
no attribute access on those objects.
38+
39+
Args:
40+
_obj: The object being accessed (unused, always blocked).
41+
_attr: The attribute name (unused, always blocked).
42+
_value: The attribute value (unused, always blocked).
43+
44+
Returns:
45+
False - all attribute access is blocked.
46+
"""
47+
# Block all attribute access
48+
return False
49+
50+
def is_safe_callable(self, _obj: Any) -> bool:
51+
"""Block all method calls for security.
52+
53+
Args:
54+
_obj: The object being checked (unused, always blocked).
55+
56+
Returns:
57+
False - all callables are blocked.
58+
"""
59+
return False
60+
61+
def getattr(self, obj: Any, attribute: str) -> Any:
62+
"""Override getattr to block all attribute access.
63+
64+
Args:
65+
obj: The object.
66+
attribute: The attribute name.
67+
68+
Returns:
69+
Never returns.
70+
71+
Raises:
72+
SecurityError: Always, to block attribute access.
73+
"""
74+
msg = (
75+
f"Access to attributes is not allowed in templates. "
76+
f"Attempted to access '{attribute}' on {type(obj).__name__}. "
77+
f"Use only simple variable names like {{{{variable}}}} "
78+
f"without dots or methods."
79+
)
80+
raise SecurityError(msg)
81+
2582
_HAS_JINJA2 = True
2683
except ImportError:
2784
_HAS_JINJA2 = False
@@ -61,14 +118,10 @@ def jinja2_formatter(template: str, /, **kwargs: Any) -> str:
61118
)
62119
raise ImportError(msg)
63120

64-
# This uses a sandboxed environment to prevent arbitrary code execution.
65-
# Jinja2 uses an opt-out rather than opt-in approach for sand-boxing.
66-
# Please treat this sand-boxing as a best-effort approach rather than
67-
# a guarantee of security.
68-
# We recommend to never use jinja2 templates with untrusted inputs.
69-
# https://jinja.palletsprojects.com/en/3.1.x/sandbox/
70-
# approach not a guarantee of security.
71-
return SandboxedEnvironment().from_string(template).render(**kwargs)
121+
# Use a restricted sandbox that blocks ALL attribute/method access
122+
# Only simple variable lookups like {{variable}} are allowed
123+
# Attribute access like {{variable.attr}} or {{variable.method()}} is blocked
124+
return _RestrictedSandboxedEnvironment().from_string(template).render(**kwargs)
72125

73126

74127
def validate_jinja2(template: str, input_variables: list[str]) -> None:
@@ -103,7 +156,7 @@ def _get_jinja2_variables_from_template(template: str) -> set[str]:
103156
"Please install it with `pip install jinja2`."
104157
)
105158
raise ImportError(msg)
106-
env = Environment() # noqa: S701
159+
env = _RestrictedSandboxedEnvironment()
107160
ast = env.parse(template)
108161
return meta.find_undeclared_variables(ast)
109162

@@ -273,6 +326,30 @@ def get_template_variables(template: str, template_format: str) -> list[str]:
273326
msg = f"Unsupported template format: {template_format}"
274327
raise ValueError(msg)
275328

329+
# For f-strings, block attribute access and indexing syntax
330+
# This prevents template injection attacks via accessing dangerous attributes
331+
if template_format == "f-string":
332+
for var in input_variables:
333+
# Formatter().parse() returns field names with dots/brackets if present
334+
# e.g., "obj.attr" or "obj[0]" - we need to block these
335+
if "." in var or "[" in var or "]" in var:
336+
msg = (
337+
f"Invalid variable name {var!r} in f-string template. "
338+
f"Variable names cannot contain attribute "
339+
f"access (.) or indexing ([])."
340+
)
341+
raise ValueError(msg)
342+
343+
# Block variable names that are all digits (e.g., "0", "100")
344+
# These are interpreted as positional arguments, not keyword arguments
345+
if var.isdigit():
346+
msg = (
347+
f"Invalid variable name {var!r} in f-string template. "
348+
f"Variable names cannot be all digits as they are interpreted "
349+
f"as positional arguments."
350+
)
351+
raise ValueError(msg)
352+
276353
return sorted(input_variables)
277354

278355

libs/core/langchain_core/utils/mustache.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -374,15 +374,29 @@ def _get_key(
374374
if resolved_scope in (0, False):
375375
return resolved_scope
376376
# Move into the scope
377-
try:
378-
# Try subscripting (Normal dictionaries)
379-
resolved_scope = cast("dict[str, Any]", resolved_scope)[child]
380-
except (TypeError, AttributeError):
377+
if isinstance(resolved_scope, dict):
381378
try:
382-
resolved_scope = getattr(resolved_scope, child)
383-
except (TypeError, AttributeError):
384-
# Try as a list
385-
resolved_scope = resolved_scope[int(child)] # type: ignore[index]
379+
resolved_scope = resolved_scope[child]
380+
except (KeyError, TypeError):
381+
# Key not found - will be caught by outer try-except
382+
msg = f"Key {child!r} not found in dict"
383+
raise KeyError(msg) from None
384+
elif isinstance(resolved_scope, (list, tuple)):
385+
try:
386+
resolved_scope = resolved_scope[int(child)]
387+
except (ValueError, IndexError, TypeError):
388+
# Invalid index - will be caught by outer try-except
389+
msg = f"Invalid index {child!r} for list/tuple"
390+
raise IndexError(msg) from None
391+
else:
392+
# Reject everything else for security
393+
# This prevents traversing into arbitrary Python objects
394+
msg = (
395+
f"Cannot traverse into {type(resolved_scope).__name__}. "
396+
"Mustache templates only support dict, list, and tuple. "
397+
f"Got: {type(resolved_scope)}"
398+
)
399+
raise TypeError(msg) # noqa: TRY301
386400

387401
try:
388402
# This allows for custom falsy data types
@@ -393,8 +407,9 @@ def _get_key(
393407
if resolved_scope in (0, False):
394408
return resolved_scope
395409
return resolved_scope or ""
396-
except (AttributeError, KeyError, IndexError, ValueError):
410+
except (AttributeError, KeyError, IndexError, ValueError, TypeError):
397411
# We couldn't find the key in the current scope
412+
# TypeError: Attempted to traverse into non-dict/list type
398413
# We'll try again on the next pass
399414
pass
400415

libs/core/tests/unit_tests/prompts/test_chat.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1540,3 +1540,164 @@ def test_rendering_prompt_with_conditionals_no_empty_text_blocks() -> None:
15401540
assert not [
15411541
block for block in content if block["type"] == "text" and block["text"] == ""
15421542
]
1543+
1544+
1545+
def test_fstring_rejects_invalid_identifier_variable_names() -> None:
1546+
"""Test that f-string templates block attribute access, indexing.
1547+
1548+
This validation prevents template injection attacks by blocking:
1549+
- Attribute access like {msg.__class__}
1550+
- Indexing like {msg[0]}
1551+
- All-digit variable names like {0} or {100} (interpreted as positional args)
1552+
1553+
While allowing any other field names that Python's Formatter accepts.
1554+
"""
1555+
# Test that attribute access and indexing are blocked (security issue)
1556+
invalid_templates = [
1557+
"{msg.__class__}", # Attribute access with dunder
1558+
"{msg.__class__.__name__}", # Multiple dunders
1559+
"{msg.content}", # Attribute access
1560+
"{msg[0]}", # Item access
1561+
"{0}", # All-digit variable name (positional argument)
1562+
"{100}", # All-digit variable name (positional argument)
1563+
"{42}", # All-digit variable name (positional argument)
1564+
]
1565+
1566+
for template_str in invalid_templates:
1567+
with pytest.raises(ValueError, match="Invalid variable name") as exc_info:
1568+
ChatPromptTemplate.from_messages(
1569+
[("human", template_str)],
1570+
template_format="f-string",
1571+
)
1572+
1573+
error_msg = str(exc_info.value)
1574+
assert "Invalid variable name" in error_msg
1575+
# Check for any of the expected error message parts
1576+
assert (
1577+
"attribute access" in error_msg
1578+
or "indexing" in error_msg
1579+
or "positional arguments" in error_msg
1580+
)
1581+
1582+
# Valid templates - Python's Formatter accepts non-identifier field names
1583+
valid_templates = [
1584+
(
1585+
"Hello {name} and {user_id}",
1586+
{"name": "Alice", "user_id": "123"},
1587+
"Hello Alice and 123",
1588+
),
1589+
("User: {user-name}", {"user-name": "Bob"}, "User: Bob"), # Hyphen allowed
1590+
(
1591+
"Value: {2fast}",
1592+
{"2fast": "Charlie"},
1593+
"Value: Charlie",
1594+
), # Starts with digit allowed
1595+
("Data: {my var}", {"my var": "Dave"}, "Data: Dave"), # Space allowed
1596+
]
1597+
1598+
for template_str, kwargs, expected in valid_templates:
1599+
template = ChatPromptTemplate.from_messages(
1600+
[("human", template_str)],
1601+
template_format="f-string",
1602+
)
1603+
result = template.invoke(kwargs)
1604+
assert result.messages[0].content == expected # type: ignore[attr-defined]
1605+
1606+
1607+
def test_mustache_template_attribute_access_vulnerability() -> None:
1608+
"""Test that Mustache template injection is blocked.
1609+
1610+
Verify the fix for security vulnerability GHSA-6qv9-48xg-fc7f
1611+
1612+
Previously, Mustache used getattr() as a fallback, allowing access to
1613+
dangerous attributes like __class__, __globals__, etc.
1614+
1615+
The fix adds isinstance checks that reject non-dict/list types.
1616+
When templates try to traverse Python objects, they get empty string
1617+
per Mustache spec (better than the previous behavior of exposing internals).
1618+
"""
1619+
msg = HumanMessage("howdy")
1620+
1621+
# Template tries to access attributes on a Python object
1622+
prompt = ChatPromptTemplate.from_messages(
1623+
[("human", "{{question.__class__.__name__}}")],
1624+
template_format="mustache",
1625+
)
1626+
1627+
# After the fix: returns empty string (attack blocked!)
1628+
# Previously would return "HumanMessage" via getattr()
1629+
result = prompt.invoke({"question": msg})
1630+
assert result.messages[0].content == "" # type: ignore[attr-defined]
1631+
1632+
# Mustache still works correctly with actual dicts
1633+
prompt_dict = ChatPromptTemplate.from_messages(
1634+
[("human", "{{person.name}}")],
1635+
template_format="mustache",
1636+
)
1637+
result_dict = prompt_dict.invoke({"person": {"name": "Alice"}})
1638+
assert result_dict.messages[0].content == "Alice" # type: ignore[attr-defined]
1639+
1640+
1641+
@pytest.mark.requires("jinja2")
1642+
def test_jinja2_template_attribute_access_is_blocked() -> None:
1643+
"""Test that Jinja2 SandboxedEnvironment blocks dangerous attribute access.
1644+
1645+
This test verifies that Jinja2's sandbox successfully blocks access to
1646+
dangerous dunder attributes like __class__, unlike Mustache.
1647+
1648+
GOOD: Jinja2 SandboxedEnvironment raises SecurityError when attempting
1649+
to access __class__, __globals__, etc. This is expected behavior.
1650+
"""
1651+
msg = HumanMessage("howdy")
1652+
1653+
# Create a Jinja2 template that attempts to access __class__.__name__
1654+
prompt = ChatPromptTemplate.from_messages(
1655+
[("human", "{{question.__class__.__name__}}")],
1656+
template_format="jinja2",
1657+
)
1658+
1659+
# Jinja2 sandbox should block this with SecurityError
1660+
with pytest.raises(Exception, match="attribute") as exc_info:
1661+
prompt.invoke(
1662+
{"question": msg, "question.__class__.__name__": "safe_placeholder"}
1663+
)
1664+
1665+
# Verify it's a SecurityError from Jinja2 blocking __class__ access
1666+
error_msg = str(exc_info.value)
1667+
assert (
1668+
"SecurityError" in str(type(exc_info.value))
1669+
or "access to attribute '__class__'" in error_msg
1670+
), f"Expected SecurityError blocking __class__, got: {error_msg}"
1671+
1672+
1673+
@pytest.mark.requires("jinja2")
1674+
def test_jinja2_blocks_all_attribute_access() -> None:
1675+
"""Test that Jinja2 now blocks ALL attribute/method access for security.
1676+
1677+
After the fix, Jinja2 uses _RestrictedSandboxedEnvironment which blocks
1678+
ALL attribute access, not just dunder attributes. This prevents the
1679+
parse_raw() vulnerability.
1680+
"""
1681+
msg = HumanMessage("test content")
1682+
1683+
# Test 1: Simple variable access should still work
1684+
prompt_simple = ChatPromptTemplate.from_messages(
1685+
[("human", "Message: {{message}}")],
1686+
template_format="jinja2",
1687+
)
1688+
result = prompt_simple.invoke({"message": "hello world"})
1689+
assert "hello world" in result.messages[0].content # type: ignore[attr-defined]
1690+
1691+
# Test 2: Attribute access should now be blocked (including safe attributes)
1692+
prompt_attr = ChatPromptTemplate.from_messages(
1693+
[("human", "Content: {{msg.content}}")],
1694+
template_format="jinja2",
1695+
)
1696+
with pytest.raises(Exception, match="attribute") as exc_info:
1697+
prompt_attr.invoke({"msg": msg})
1698+
1699+
error_msg = str(exc_info.value)
1700+
assert (
1701+
"SecurityError" in str(type(exc_info.value))
1702+
or "Access to attributes is not allowed" in error_msg
1703+
), f"Expected SecurityError blocking attribute access, got: {error_msg}"

0 commit comments

Comments
 (0)