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