@@ -226,10 +226,15 @@ fn render_template_inner(
226226 i += end_pos + end_tag. len ( ) ;
227227
228228 if let Some ( serde_json:: Value :: Array ( items) ) = json_data. get ( block_var) {
229+ // THREAT[TM-TINJ-001]: Escape template markers in data values
230+ // to prevent injection. Data containing `{{VAR}}` must not be
231+ // re-evaluated as template directives.
232+ const SENTINEL : & str = "\x00 BK_LBRACE\x00 " ;
229233 for item in items {
230- // Replace {{.}} with current item value
231234 let item_str = json_value_to_string ( item) ;
232- let rendered_body = block_body. replace ( "{{.}}" , & item_str) ;
235+ // Escape {{ in data values so they survive render_template_inner
236+ let safe_item = item_str. replace ( "{{" , SENTINEL ) ;
237+ let rendered_body = block_body. replace ( "{{.}}" , & safe_item) ;
233238 let rendered = render_template_inner (
234239 & rendered_body,
235240 json_data,
@@ -239,7 +244,8 @@ fn render_template_inner(
239244 strict,
240245 depth + 1 ,
241246 ) ?;
242- output. push_str ( & rendered) ;
247+ // Restore escaped braces after rendering
248+ output. push_str ( & rendered. replace ( SENTINEL , "{{" ) ) ;
243249 }
244250 } else if strict {
245251 return Err ( format ! (
@@ -612,4 +618,71 @@ mod tests {
612618 assert_eq ! ( result. exit_code, 1 ) ;
613619 assert ! ( result. stderr. contains( "template:" ) ) ;
614620 }
621+
622+ // THREAT[TM-TINJ-001]: Template injection via #each data values
623+
624+ #[ tokio:: test]
625+ async fn test_each_data_injection_blocked ( ) {
626+ // Data containing {{SECRET_KEY}} must produce the literal string,
627+ // NOT the variable's value.
628+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) ;
629+ let fs_dyn = fs. clone ( ) as Arc < dyn crate :: fs:: FileSystem > ;
630+ fs_dyn
631+ . write_file (
632+ std:: path:: Path :: new ( "/data.json" ) ,
633+ br#"{"items": ["normal", "{{SECRET_KEY}}", "also_normal"]}"# ,
634+ )
635+ . await
636+ . unwrap ( ) ;
637+ let mut vars = HashMap :: new ( ) ;
638+ vars. insert ( "SECRET_KEY" . to_string ( ) , "s3cr3t_value_123" . to_string ( ) ) ;
639+ let result = run_template (
640+ & [ "-d" , "data.json" ] ,
641+ Some ( "{{#each items}}[{{.}}]{{/each}}" ) ,
642+ vars,
643+ HashMap :: new ( ) ,
644+ fs,
645+ )
646+ . await ;
647+ assert_eq ! ( result. exit_code, 0 ) ;
648+ // The secret value must NOT appear in output
649+ assert ! (
650+ !result. stdout. contains( "s3cr3t_value_123" ) ,
651+ "secret leaked: {}" ,
652+ result. stdout
653+ ) ;
654+ // The literal {{SECRET_KEY}} should be preserved
655+ assert ! ( result. stdout. contains( "{{SECRET_KEY}}" ) ) ;
656+ }
657+
658+ #[ tokio:: test]
659+ async fn test_each_nested_directive_in_data_not_evaluated ( ) {
660+ // Data containing {{#if ...}} must NOT be evaluated as a directive.
661+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) ;
662+ let fs_dyn = fs. clone ( ) as Arc < dyn crate :: fs:: FileSystem > ;
663+ fs_dyn
664+ . write_file (
665+ std:: path:: Path :: new ( "/data.json" ) ,
666+ br#"{"items": ["{{#if true}}injected{{/if}}"]}"# ,
667+ )
668+ . await
669+ . unwrap ( ) ;
670+ let result = run_template (
671+ & [ "-d" , "data.json" ] ,
672+ Some ( "{{#each items}}[{{.}}]{{/each}}" ) ,
673+ HashMap :: new ( ) ,
674+ HashMap :: new ( ) ,
675+ fs,
676+ )
677+ . await ;
678+ assert_eq ! ( result. exit_code, 0 ) ;
679+ // The directive must appear literally, not evaluated.
680+ // If evaluated, output would be just "[injected]"; instead it should
681+ // contain the raw directive text.
682+ assert ! (
683+ result. stdout. contains( "{{#if true}}" ) ,
684+ "directive was stripped/evaluated: {}" ,
685+ result. stdout
686+ ) ;
687+ }
615688}
0 commit comments