Skip to content

Commit fa7789d

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

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
@@ -17,9 +17,66 @@
1717
from langchain_core.utils.interactive_env import is_interactive_env
1818

1919
try:
20-
from jinja2 import Environment, meta
20+
from jinja2 import meta
21+
from jinja2.exceptions import SecurityError
2122
from jinja2.sandbox import SandboxedEnvironment
2223

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

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

71124

72125
def validate_jinja2(template: str, input_variables: list[str]) -> None:
@@ -101,7 +154,7 @@ def _get_jinja2_variables_from_template(template: str) -> set[str]:
101154
"Please install it with `pip install jinja2`."
102155
)
103156
raise ImportError(msg)
104-
env = Environment() # noqa: S701
157+
env = _RestrictedSandboxedEnvironment()
105158
ast = env.parse(template)
106159
return meta.find_undeclared_variables(ast)
107160

@@ -268,6 +321,30 @@ def get_template_variables(template: str, template_format: str) -> list[str]:
268321
msg = f"Unsupported template format: {template_format}"
269322
raise ValueError(msg)
270323

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

273350

libs/core/langchain_core/utils/mustache.py

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

389403
try:
390404
# This allows for custom falsy data types
@@ -395,8 +409,9 @@ def _get_key(
395409
if resolved_scope in (0, False):
396410
return resolved_scope
397411
return resolved_scope or ""
398-
except (AttributeError, KeyError, IndexError, ValueError):
412+
except (AttributeError, KeyError, IndexError, ValueError, TypeError):
399413
# We couldn't find the key in the current scope
414+
# TypeError: Attempted to traverse into non-dict/list type
400415
# We'll try again on the next pass
401416
pass
402417

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

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,3 +1236,164 @@ def test_dict_message_prompt_template_errors_on_jinja2() -> None:
12361236
_ = ChatPromptTemplate.from_messages(
12371237
[("human", [prompt])], template_format="jinja2"
12381238
)
1239+
1240+
1241+
def test_fstring_rejects_invalid_identifier_variable_names() -> None:
1242+
"""Test that f-string templates block attribute access, indexing.
1243+
1244+
This validation prevents template injection attacks by blocking:
1245+
- Attribute access like {msg.__class__}
1246+
- Indexing like {msg[0]}
1247+
- All-digit variable names like {0} or {100} (interpreted as positional args)
1248+
1249+
While allowing any other field names that Python's Formatter accepts.
1250+
"""
1251+
# Test that attribute access and indexing are blocked (security issue)
1252+
invalid_templates = [
1253+
"{msg.__class__}", # Attribute access with dunder
1254+
"{msg.__class__.__name__}", # Multiple dunders
1255+
"{msg.content}", # Attribute access
1256+
"{msg[0]}", # Item access
1257+
"{0}", # All-digit variable name (positional argument)
1258+
"{100}", # All-digit variable name (positional argument)
1259+
"{42}", # All-digit variable name (positional argument)
1260+
]
1261+
1262+
for template_str in invalid_templates:
1263+
with pytest.raises(ValueError, match="Invalid variable name") as exc_info:
1264+
ChatPromptTemplate.from_messages(
1265+
[("human", template_str)],
1266+
template_format="f-string",
1267+
)
1268+
1269+
error_msg = str(exc_info.value)
1270+
assert "Invalid variable name" in error_msg
1271+
# Check for any of the expected error message parts
1272+
assert (
1273+
"attribute access" in error_msg
1274+
or "indexing" in error_msg
1275+
or "positional arguments" in error_msg
1276+
)
1277+
1278+
# Valid templates - Python's Formatter accepts non-identifier field names
1279+
valid_templates = [
1280+
(
1281+
"Hello {name} and {user_id}",
1282+
{"name": "Alice", "user_id": "123"},
1283+
"Hello Alice and 123",
1284+
),
1285+
("User: {user-name}", {"user-name": "Bob"}, "User: Bob"), # Hyphen allowed
1286+
(
1287+
"Value: {2fast}",
1288+
{"2fast": "Charlie"},
1289+
"Value: Charlie",
1290+
), # Starts with digit allowed
1291+
("Data: {my var}", {"my var": "Dave"}, "Data: Dave"), # Space allowed
1292+
]
1293+
1294+
for template_str, kwargs, expected in valid_templates:
1295+
template = ChatPromptTemplate.from_messages(
1296+
[("human", template_str)],
1297+
template_format="f-string",
1298+
)
1299+
result = template.invoke(kwargs)
1300+
assert result.messages[0].content == expected # type: ignore[attr-defined]
1301+
1302+
1303+
def test_mustache_template_attribute_access_vulnerability() -> None:
1304+
"""Test that Mustache template injection is blocked.
1305+
1306+
Verify the fix for security vulnerability GHSA-6qv9-48xg-fc7f
1307+
1308+
Previously, Mustache used getattr() as a fallback, allowing access to
1309+
dangerous attributes like __class__, __globals__, etc.
1310+
1311+
The fix adds isinstance checks that reject non-dict/list types.
1312+
When templates try to traverse Python objects, they get empty string
1313+
per Mustache spec (better than the previous behavior of exposing internals).
1314+
"""
1315+
msg = HumanMessage("howdy")
1316+
1317+
# Template tries to access attributes on a Python object
1318+
prompt = ChatPromptTemplate.from_messages(
1319+
[("human", "{{question.__class__.__name__}}")],
1320+
template_format="mustache",
1321+
)
1322+
1323+
# After the fix: returns empty string (attack blocked!)
1324+
# Previously would return "HumanMessage" via getattr()
1325+
result = prompt.invoke({"question": msg})
1326+
assert result.messages[0].content == "" # type: ignore[attr-defined]
1327+
1328+
# Mustache still works correctly with actual dicts
1329+
prompt_dict = ChatPromptTemplate.from_messages(
1330+
[("human", "{{person.name}}")],
1331+
template_format="mustache",
1332+
)
1333+
result_dict = prompt_dict.invoke({"person": {"name": "Alice"}})
1334+
assert result_dict.messages[0].content == "Alice" # type: ignore[attr-defined]
1335+
1336+
1337+
@pytest.mark.requires("jinja2")
1338+
def test_jinja2_template_attribute_access_is_blocked() -> None:
1339+
"""Test that Jinja2 SandboxedEnvironment blocks dangerous attribute access.
1340+
1341+
This test verifies that Jinja2's sandbox successfully blocks access to
1342+
dangerous dunder attributes like __class__, unlike Mustache.
1343+
1344+
GOOD: Jinja2 SandboxedEnvironment raises SecurityError when attempting
1345+
to access __class__, __globals__, etc. This is expected behavior.
1346+
"""
1347+
msg = HumanMessage("howdy")
1348+
1349+
# Create a Jinja2 template that attempts to access __class__.__name__
1350+
prompt = ChatPromptTemplate.from_messages(
1351+
[("human", "{{question.__class__.__name__}}")],
1352+
template_format="jinja2",
1353+
)
1354+
1355+
# Jinja2 sandbox should block this with SecurityError
1356+
with pytest.raises(Exception, match="attribute") as exc_info:
1357+
prompt.invoke(
1358+
{"question": msg, "question.__class__.__name__": "safe_placeholder"}
1359+
)
1360+
1361+
# Verify it's a SecurityError from Jinja2 blocking __class__ access
1362+
error_msg = str(exc_info.value)
1363+
assert (
1364+
"SecurityError" in str(type(exc_info.value))
1365+
or "access to attribute '__class__'" in error_msg
1366+
), f"Expected SecurityError blocking __class__, got: {error_msg}"
1367+
1368+
1369+
@pytest.mark.requires("jinja2")
1370+
def test_jinja2_blocks_all_attribute_access() -> None:
1371+
"""Test that Jinja2 now blocks ALL attribute/method access for security.
1372+
1373+
After the fix, Jinja2 uses _RestrictedSandboxedEnvironment which blocks
1374+
ALL attribute access, not just dunder attributes. This prevents the
1375+
parse_raw() vulnerability.
1376+
"""
1377+
msg = HumanMessage("test content")
1378+
1379+
# Test 1: Simple variable access should still work
1380+
prompt_simple = ChatPromptTemplate.from_messages(
1381+
[("human", "Message: {{message}}")],
1382+
template_format="jinja2",
1383+
)
1384+
result = prompt_simple.invoke({"message": "hello world"})
1385+
assert "hello world" in result.messages[0].content # type: ignore[attr-defined]
1386+
1387+
# Test 2: Attribute access should now be blocked (including safe attributes)
1388+
prompt_attr = ChatPromptTemplate.from_messages(
1389+
[("human", "Content: {{msg.content}}")],
1390+
template_format="jinja2",
1391+
)
1392+
with pytest.raises(Exception, match="attribute") as exc_info:
1393+
prompt_attr.invoke({"msg": msg})
1394+
1395+
error_msg = str(exc_info.value)
1396+
assert (
1397+
"SecurityError" in str(type(exc_info.value))
1398+
or "Access to attributes is not allowed" in error_msg
1399+
), f"Expected SecurityError blocking attribute access, got: {error_msg}"

0 commit comments

Comments
 (0)