@@ -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