Skip to content

Commit 6f4a6dd

Browse files
authored
fix(safeoutputs): block VSO command injection via repository alias across Stage 3 PR-safe-output executors (#482)
1 parent 3e67cfd commit 6f4a6dd

8 files changed

Lines changed: 243 additions & 9 deletions

File tree

src/safeoutputs/add_pr_comment.rs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ use serde::{Deserialize, Serialize};
77

88
use super::PATH_SEGMENT;
99
use ado_aw_derive::SanitizeConfig;
10-
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text};
10+
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config};
1111
use crate::tool_result;
1212
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, Validate};
13+
use crate::validate::reject_pipeline_injection;
1314
use anyhow::{Context, ensure};
1415

1516
/// Parameters for adding a comment thread on a pull request
@@ -89,6 +90,7 @@ impl Validate for AddPrCommentParams {
8990
if let Some(fp) = &self.file_path {
9091
validate_file_path(fp)?;
9192
}
93+
reject_pipeline_injection(&self.repository, "repository")?;
9294
Ok(())
9395
}
9496
}
@@ -112,8 +114,8 @@ tool_result! {
112114
impl SanitizeContent for AddPrCommentResult {
113115
fn sanitize_content_fields(&mut self) {
114116
self.content = sanitize_text(&self.content);
115-
// Strip control characters from structural fields for defense-in-depth
116-
self.repository = self.repository.chars().filter(|c| !c.is_control()).collect();
117+
self.repository = sanitize_config(&self.repository);
118+
// Strip control characters from remaining structural fields for defense-in-depth
117119
self.status = self.status.chars().filter(|c| !c.is_control()).collect();
118120
self.file_path = self.file_path.as_ref().map(|fp| {
119121
fp.chars().filter(|c| !c.is_control()).collect()
@@ -463,6 +465,21 @@ mod tests {
463465
assert!(result.is_err());
464466
}
465467

468+
#[test]
469+
fn test_validation_rejects_repository_pipeline_command() {
470+
let params = AddPrCommentParams {
471+
pull_request_id: 42,
472+
content: "This is a valid comment body text.".to_string(),
473+
repository: "##vso[task.setvariable variable=x]y".to_string(),
474+
file_path: None,
475+
start_line: None,
476+
line: None,
477+
status: "active".to_string(),
478+
};
479+
let result: Result<AddPrCommentResult, _> = params.try_into();
480+
assert!(result.is_err());
481+
}
482+
466483
#[test]
467484
fn test_validation_rejects_line_without_file_path() {
468485
let params = AddPrCommentParams {
@@ -632,4 +649,33 @@ allowed-statuses:
632649
"uppercase 'Active' should match config value 'active'"
633650
);
634651
}
652+
653+
#[test]
654+
fn test_sanitize_content_neutralizes_repository_pipeline_command() {
655+
let params = AddPrCommentParams {
656+
pull_request_id: 42,
657+
content: "This is a valid comment body text.".to_string(),
658+
repository: "##vso[task.setvariable variable=x]y".to_string(),
659+
file_path: None,
660+
start_line: None,
661+
line: None,
662+
status: "active".to_string(),
663+
};
664+
let mut result = AddPrCommentResult {
665+
name: "add-pr-comment".to_string(),
666+
pull_request_id: params.pull_request_id,
667+
content: params.content,
668+
repository: params.repository,
669+
file_path: params.file_path,
670+
start_line: params.start_line,
671+
line: params.line,
672+
status: params.status,
673+
};
674+
result.sanitize_content_fields();
675+
assert!(
676+
result.repository.contains("`##vso[`"),
677+
"repository pipeline command should be neutralized with backticks: {}",
678+
result.repository
679+
);
680+
}
635681
}

src/safeoutputs/create_branch.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use super::{PATH_SEGMENT, validate_git_ref_name};
1010
use crate::sanitize::{SanitizeContent, sanitize_config};
1111
use crate::tool_result;
1212
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, Validate};
13+
use crate::validate::reject_pipeline_injection;
1314
use anyhow::{Context, ensure};
1415

1516
/// Parameters for creating a branch
@@ -71,6 +72,9 @@ impl Validate for CreateBranchParams {
7172
);
7273
validate_git_ref_name(branch, "source_branch")?;
7374
}
75+
if let Some(repository) = &self.repository {
76+
reject_pipeline_injection(repository, "repository")?;
77+
}
7478

7579
Ok(())
7680
}
@@ -92,6 +96,7 @@ tool_result! {
9296
impl SanitizeContent for CreateBranchResult {
9397
fn sanitize_content_fields(&mut self) {
9498
self.branch_name = sanitize_config(&self.branch_name);
99+
self.repository = self.repository.as_deref().map(sanitize_config);
95100
}
96101
}
97102

@@ -459,6 +464,18 @@ mod tests {
459464
assert!(result.is_err());
460465
}
461466

467+
#[test]
468+
fn test_validation_rejects_repository_pipeline_command() {
469+
let params = CreateBranchParams {
470+
branch_name: "feature/new-work".to_string(),
471+
source_branch: Some("main".to_string()),
472+
source_commit: None,
473+
repository: Some("##vso[task.setvariable variable=x]y".to_string()),
474+
};
475+
let result: Result<CreateBranchResult, _> = params.try_into();
476+
assert!(result.is_err());
477+
}
478+
462479
#[test]
463480
fn test_validation_rejects_branch_starting_with_dash() {
464481
let params = CreateBranchParams {
@@ -546,4 +563,22 @@ allowed-source-branches:
546563
assert_eq!(config.allowed_repositories, vec!["self", "other-repo"]);
547564
assert_eq!(config.allowed_source_branches, vec!["main", "develop"]);
548565
}
566+
567+
#[test]
568+
fn test_sanitize_content_neutralizes_repository_pipeline_command() {
569+
let mut result = CreateBranchResult {
570+
name: "create-branch".to_string(),
571+
branch_name: "feature/new-work".to_string(),
572+
source_branch: Some("main".to_string()),
573+
source_commit: None,
574+
repository: Some("##vso[task.setvariable variable=x]y".to_string()),
575+
};
576+
result.sanitize_content_fields();
577+
let repository = result.repository.as_deref().unwrap_or("");
578+
assert!(
579+
repository.contains("`##vso[`"),
580+
"repository pipeline command should be neutralized with backticks: {}",
581+
repository
582+
);
583+
}
549584
}

src/safeoutputs/create_git_tag.rs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ use schemars::JsonSchema;
77
use serde::{Deserialize, Serialize};
88

99
use super::{PATH_SEGMENT, validate_git_ref_name};
10-
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text};
10+
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config};
1111
use crate::tool_result;
1212
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, Validate};
13+
use crate::validate::reject_pipeline_injection;
1314
use anyhow::{Context, ensure};
1415

1516
/// Parameters for creating a git tag (agent-provided)
@@ -75,6 +76,9 @@ impl Validate for CreateGitTagParams {
7576
"message must be at least 5 characters"
7677
);
7778
}
79+
if let Some(repository) = &self.repository {
80+
reject_pipeline_injection(repository, "repository")?;
81+
}
7882

7983
Ok(())
8084
}
@@ -106,9 +110,7 @@ impl SanitizeContent for CreateGitTagResult {
106110
self.commit = self.commit.as_ref().map(|c| {
107111
c.chars().filter(|ch| !ch.is_control()).collect()
108112
});
109-
self.repository = self.repository.as_ref().map(|r| {
110-
r.chars().filter(|ch| !ch.is_control()).collect()
111-
});
113+
self.repository = self.repository.as_deref().map(sanitize_config);
112114
}
113115
}
114116

@@ -477,6 +479,18 @@ mod tests {
477479
assert!(result.is_err());
478480
}
479481

482+
#[test]
483+
fn test_validation_rejects_repository_pipeline_command() {
484+
let params = CreateGitTagParams {
485+
tag_name: "v1.0.0".to_string(),
486+
commit: None,
487+
message: Some("Valid message".to_string()),
488+
repository: Some("##vso[task.setvariable variable=x]y".to_string()),
489+
};
490+
let result: Result<CreateGitTagResult, _> = params.try_into();
491+
assert!(result.is_err());
492+
}
493+
480494
#[test]
481495
fn test_result_serializes_correctly() {
482496
let params = CreateGitTagParams {
@@ -517,4 +531,22 @@ message-prefix: "[release] "
517531
assert_eq!(config.allowed_repositories, vec!["self", "my-lib"]);
518532
assert_eq!(config.message_prefix.as_deref(), Some("[release] "));
519533
}
534+
535+
#[test]
536+
fn test_sanitize_content_neutralizes_repository_pipeline_command() {
537+
let mut result = CreateGitTagResult {
538+
name: "create-git-tag".to_string(),
539+
tag_name: "v1.0.0".to_string(),
540+
commit: None,
541+
message: Some("Valid message".to_string()),
542+
repository: Some("##vso[task.setvariable variable=x]y".to_string()),
543+
};
544+
result.sanitize_content_fields();
545+
let repository = result.repository.as_deref().unwrap_or("");
546+
assert!(
547+
repository.contains("`##vso[`"),
548+
"repository pipeline command should be neutralized with backticks: {}",
549+
repository
550+
);
551+
}
520552
}

src/safeoutputs/create_pr.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use ado_aw_derive::SanitizeConfig;
99
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, ToolResult, Validate};
1010
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config};
1111
use crate::tool_result;
12+
use crate::validate::reject_pipeline_injection;
1213
use anyhow::{Context, ensure};
1314

1415
/// Maximum allowed patch file size (5 MB)
@@ -254,6 +255,9 @@ impl Validate for CreatePrParams {
254255
self.description.len() >= 10,
255256
"PR description must be at least 10 characters"
256257
);
258+
if let Some(repository) = &self.repository {
259+
reject_pipeline_injection(repository, "repository")?;
260+
}
257261
Ok(())
258262
}
259263
}
@@ -320,6 +324,7 @@ impl SanitizeContent for CreatePrResult {
320324
fn sanitize_content_fields(&mut self) {
321325
self.title = sanitize_text(&self.title);
322326
self.description = sanitize_text(&self.description);
327+
self.repository = sanitize_config(&self.repository);
323328
for label in &mut self.agent_labels {
324329
*label = sanitize_config(label);
325330
}
@@ -2092,6 +2097,37 @@ mod tests {
20922097
assert!(params.validate().is_err());
20932098
}
20942099

2100+
#[test]
2101+
fn test_validate_params_rejects_repository_pipeline_command() {
2102+
let params = CreatePrParams {
2103+
title: "Fix bug in parser".to_string(),
2104+
description: "This PR fixes a critical bug in the parser module.".to_string(),
2105+
repository: Some("##vso[task.setvariable variable=x]y".to_string()),
2106+
labels: vec![],
2107+
};
2108+
assert!(params.validate().is_err());
2109+
}
2110+
2111+
#[test]
2112+
fn test_sanitize_content_neutralizes_repository_pipeline_command() {
2113+
let mut result = CreatePrResult::new(
2114+
"Fix bug in parser".to_string(),
2115+
"This PR fixes a critical bug in the parser module.".to_string(),
2116+
"agent/fix-parser".to_string(),
2117+
"/tmp/test.patch".to_string(),
2118+
"##vso[task.setvariable variable=x]y".to_string(),
2119+
vec![],
2120+
None,
2121+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
2122+
);
2123+
result.sanitize_content_fields();
2124+
assert!(
2125+
result.repository.contains("`##vso[`"),
2126+
"repository pipeline command should be neutralized with backticks: {}",
2127+
result.repository
2128+
);
2129+
}
2130+
20952131
#[test]
20962132
fn test_config_default_target_branch() {
20972133
let config = CreatePrConfig::default();

src/safeoutputs/reply_to_pr_comment.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ use schemars::JsonSchema;
77
use serde::{Deserialize, Serialize};
88

99
use super::PATH_SEGMENT;
10-
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text};
10+
use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config};
1111
use crate::tool_result;
1212
use crate::safeoutputs::{ExecutionContext, ExecutionResult, Executor, Validate};
13+
use crate::validate::reject_pipeline_injection;
1314
use anyhow::{Context, ensure};
1415

1516
/// Parameters for replying to an existing review comment thread on a pull request
@@ -42,6 +43,9 @@ impl Validate for ReplyToPrCommentParams {
4243
self.content.len() >= 10,
4344
"content must be at least 10 characters"
4445
);
46+
if let Some(repository) = &self.repository {
47+
reject_pipeline_injection(repository, "repository")?;
48+
}
4549
Ok(())
4650
}
4751
}
@@ -62,6 +66,7 @@ tool_result! {
6266
impl SanitizeContent for ReplyToPrCommentResult {
6367
fn sanitize_content_fields(&mut self) {
6468
self.content = sanitize_text(&self.content);
69+
self.repository = self.repository.as_deref().map(sanitize_config);
6570
}
6671
}
6772

@@ -318,6 +323,18 @@ mod tests {
318323
assert!(result.is_err());
319324
}
320325

326+
#[test]
327+
fn test_validation_rejects_repository_pipeline_command() {
328+
let params = ReplyToPrCommentParams {
329+
pull_request_id: 42,
330+
thread_id: 7,
331+
content: "This is a valid reply body text.".to_string(),
332+
repository: Some("##vso[task.setvariable variable=x]y".to_string()),
333+
};
334+
let result: Result<ReplyToPrCommentResult, _> = params.try_into();
335+
assert!(result.is_err());
336+
}
337+
321338
#[test]
322339
fn test_result_serializes_correctly() {
323340
let params = ReplyToPrCommentParams {
@@ -353,4 +370,22 @@ allowed-repositories:
353370
assert_eq!(config.comment_prefix, Some("[Agent] ".to_string()));
354371
assert_eq!(config.allowed_repositories, vec!["self", "other-repo"]);
355372
}
373+
374+
#[test]
375+
fn test_sanitize_content_neutralizes_repository_pipeline_command() {
376+
let mut result = ReplyToPrCommentResult {
377+
name: "reply-to-pr-review-comment".to_string(),
378+
pull_request_id: 42,
379+
thread_id: 7,
380+
content: "This is a valid reply body text.".to_string(),
381+
repository: Some("##vso[task.setvariable variable=x]y".to_string()),
382+
};
383+
result.sanitize_content_fields();
384+
let repository = result.repository.as_deref().unwrap_or("");
385+
assert!(
386+
repository.contains("`##vso[`"),
387+
"repository pipeline command should be neutralized with backticks: {}",
388+
repository
389+
);
390+
}
356391
}

0 commit comments

Comments
 (0)