Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ pub fn generate_parameters(parameters: &[PipelineParameter]) -> Result<String> {
/// Validate front matter `name` and `description` fields.
///
/// These values are substituted directly into the pipeline YAML template and must not
/// contain ADO expressions (`${{`, `$(`, `$[`) which could disclose secrets or manipulate
/// pipeline logic. Newlines are also rejected to prevent YAML structure injection.
/// contain ADO expressions (`${{`, `$(`, `$[`), the compiler's own template marker
/// delimiter (`{{`), or newlines — any of which could disclose secrets or manipulate
/// pipeline logic via second-order injection.
pub fn validate_front_matter_identity(front_matter: &FrontMatter) -> Result<()> {
for (field, value) in [("name", &front_matter.name), ("description", &front_matter.description)] {
validate::reject_pipeline_injection(value, field)?;
Expand Down Expand Up @@ -3146,6 +3147,24 @@ mod tests {
assert!(result.unwrap_err().to_string().contains("single line"));
}

#[test]
fn test_validate_front_matter_identity_rejects_template_marker_in_name() {
let mut fm = minimal_front_matter();
fm.name = "{{ agent_content }}".to_string();
let result = validate_front_matter_identity(&fm);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("template marker"));
}

#[test]
fn test_validate_front_matter_identity_rejects_template_marker_in_description() {
let mut fm = minimal_front_matter();
fm.description = "{{ copilot_params }}".to_string();
let result = validate_front_matter_identity(&fm);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("template marker"));
}

#[test]
fn test_validate_front_matter_identity_rejects_newline_in_trigger_pipeline_name() {
let mut fm = minimal_front_matter();
Expand Down
42 changes: 40 additions & 2 deletions src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ pub fn contains_newline(s: &str) -> bool {
s.contains('\n') || s.contains('\r')
}

/// Returns true if the string contains the compiler's template marker
/// delimiter (`{{`). Values substituted into the pipeline template must
/// not contain this sequence — otherwise a second-order substitution can
/// inject arbitrary content (e.g., `{{ agent_content }}` in the `name`
/// field would be expanded by a later replacement pass).
pub fn contains_template_marker(s: &str) -> bool {
s.contains("{{")
}

/// Reject ADO template expressions (`${{`), macro expressions (`$(`), and runtime
/// expressions (`$[`) in a string value. Parameter definitions should only contain
/// literal values — expressions could enable information disclosure or logic manipulation
Expand Down Expand Up @@ -135,8 +144,8 @@ pub fn reject_ado_expressions_in_value(
}

/// Reject values that could cause pipeline injection: ADO expressions,
/// pipeline commands (`##vso[`, `##[`), and newlines. A combined check
/// for fields embedded into YAML templates.
/// pipeline commands (`##vso[`, `##[`), template markers (`{{`), and
/// newlines. A combined check for fields embedded into YAML templates.
pub fn reject_pipeline_injection(value: &str, field_name: &str) -> Result<()> {
if contains_ado_expression(value) {
anyhow::bail!(
Expand All @@ -146,6 +155,22 @@ pub fn reject_pipeline_injection(value: &str, field_name: &str) -> Result<()> {
value,
);
}
if contains_pipeline_command(value) {
anyhow::bail!(
"Front matter '{}' contains an ADO pipeline command ('##vso[' or '##[') which is not allowed. \
Pipeline commands could manipulate pipeline behavior. Found: '{}'",
field_name,
value,
);
}
if contains_template_marker(value) {
anyhow::bail!(
"Front matter '{}' contains a template marker delimiter '{{{{' which is not allowed. \
Template markers could cause second-order injection into the generated pipeline. Found: '{}'",
field_name,
value,
);
}
if contains_newline(value) {
anyhow::bail!(
"Front matter '{}' must be a single line (no newlines). \
Expand Down Expand Up @@ -481,6 +506,15 @@ mod tests {
assert!(!contains_newline("single line"));
}

#[test]
fn test_contains_template_marker() {
assert!(contains_template_marker("{{ agent_content }}"));
assert!(contains_template_marker("prefix {{ something }} suffix"));
assert!(contains_template_marker("{{no_spaces}}"));
assert!(!contains_template_marker("normal text"));
assert!(!contains_template_marker("just a single { brace"));
}

#[test]
fn test_reject_ado_expressions() {
assert!(reject_ado_expressions("normal value", "param", "field").is_ok());
Expand All @@ -494,6 +528,10 @@ mod tests {
assert!(reject_pipeline_injection("normal value", "field").is_ok());
assert!(reject_pipeline_injection("$(SYSTEM_ACCESSTOKEN)", "field").is_err());
assert!(reject_pipeline_injection("value\ninjected", "field").is_err());
assert!(reject_pipeline_injection("{{ agent_content }}", "field").is_err());
assert!(reject_pipeline_injection("{{ copilot_params }}", "field").is_err());
assert!(reject_pipeline_injection("##vso[task.setvariable]x", "field").is_err());
assert!(reject_pipeline_injection("##[section]foo", "field").is_err());
}

// ── DNS domain validators ───────────────────────────────────────────
Expand Down
Loading