Skip to content

Commit 948ff27

Browse files
authored
fix(template): prevent injection via #each data values (#1240)
Closes #1164
1 parent c539ba9 commit 948ff27

1 file changed

Lines changed: 76 additions & 3 deletions

File tree

crates/bashkit/src/builtins/template.rs

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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 = "\x00BK_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

Comments
 (0)