Skip to content

Commit 48b8cac

Browse files
shanselmanCopilot
andcommitted
security: sanitize terminal control characters from winget output
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9d396ba commit 48b8cac

File tree

1 file changed

+54
-17
lines changed

1 file changed

+54
-17
lines changed

src/cli_backend.rs

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ use crate::models::{Package, PackageDetail, Source};
99

1010
pub struct CliBackend;
1111

12+
/// Strip ASCII control characters (0x00–0x1F, 0x7F) except tab and newline.
13+
/// Prevents ANSI escape injection from malicious package metadata.
14+
fn sanitize_text(s: &str) -> String {
15+
s.chars()
16+
.filter(|&c| c == '\t' || c == '\n' || (c >= ' ' && c != '\x7F'))
17+
.collect()
18+
}
19+
1220
impl CliBackend {
1321
pub fn new() -> Self {
1422
Self
@@ -240,11 +248,11 @@ impl CliBackend {
240248
}
241249

242250
Some(Package {
243-
name: name_idx.map(&field).unwrap_or_default(),
244-
id,
245-
version: ver_idx.map(&field).unwrap_or_default(),
246-
source: source_idx.map(&field).unwrap_or_default(),
247-
available_version: avail_idx.map(&field).unwrap_or_default(),
251+
name: sanitize_text(&name_idx.map(&field).unwrap_or_default()),
252+
id: sanitize_text(&id),
253+
version: sanitize_text(&ver_idx.map(&field).unwrap_or_default()),
254+
source: sanitize_text(&source_idx.map(&field).unwrap_or_default()),
255+
available_version: sanitize_text(&avail_idx.map(&field).unwrap_or_default()),
248256
})
249257
}
250258

@@ -266,11 +274,13 @@ impl CliBackend {
266274
if bracket_end > bracket_start && !trimmed.contains(':') {
267275
let before_bracket = trimmed[..bracket_start].trim();
268276
// Skip the prefix word ("Found", "Gefunden", etc.)
269-
detail.name = before_bracket
270-
.split_once(' ')
271-
.map(|(_, name)| name.trim().to_string())
272-
.unwrap_or_default();
273-
detail.id = trimmed[bracket_start + 1..bracket_end].to_string();
277+
detail.name = sanitize_text(
278+
&before_bracket
279+
.split_once(' ')
280+
.map(|(_, name)| name.trim().to_string())
281+
.unwrap_or_default(),
282+
);
283+
detail.id = sanitize_text(&trimmed[bracket_start + 1..bracket_end].to_string());
274284
i += 1;
275285
continue;
276286
}
@@ -282,8 +292,8 @@ impl CliBackend {
282292
let key = key.trim();
283293
let value = value.trim().to_string();
284294
match Self::normalize_show_key(key) {
285-
"version" => detail.version = value,
286-
"publisher" => detail.publisher = value,
295+
"version" => detail.version = sanitize_text(&value),
296+
"publisher" => detail.publisher = sanitize_text(&value),
287297
"description" => {
288298
// Description value may be on this line or on indented continuation lines
289299
let mut desc = value;
@@ -294,16 +304,16 @@ impl CliBackend {
294304
}
295305
desc.push_str(lines[i].trim());
296306
}
297-
detail.description = desc;
307+
detail.description = sanitize_text(&desc);
298308
}
299-
"homepage" => detail.homepage = value,
309+
"homepage" => detail.homepage = sanitize_text(&value),
300310
"publisher_url" => {
301311
if detail.homepage.is_empty() {
302-
detail.homepage = value;
312+
detail.homepage = sanitize_text(&value);
303313
}
304314
}
305-
"license" => detail.license = value,
306-
"source" => detail.source = value,
315+
"license" => detail.license = sanitize_text(&value),
316+
"source" => detail.source = sanitize_text(&value),
307317
_ => {}
308318
}
309319
}
@@ -708,4 +718,31 @@ Microsoft Visual Studio Code Microsoft.VisualStudioCode 1.95.3 1.96.0
708718
assert_eq!(packages[0].id, "Google.Chrome");
709719
assert_eq!(packages[1].id, "Microsoft.VisualStudioCode");
710720
}
721+
722+
#[test]
723+
fn sanitize_strips_ansi_escape_from_package_name() {
724+
// Direct test of sanitize_text helper
725+
let dirty = "Evil\x1b]52;c;payload\x07App";
726+
let clean = super::sanitize_text(dirty);
727+
assert!(!clean.contains('\x1b'), "ESC must be stripped");
728+
assert!(!clean.contains('\x07'), "BEL must be stripped");
729+
assert_eq!(clean, "Evil]52;c;payloadApp");
730+
731+
// Verify tab and newline are preserved
732+
assert_eq!(super::sanitize_text("a\tb\nc"), "a\tb\nc");
733+
734+
// End-to-end: package table with embedded escape in name
735+
let backend = CliBackend::new();
736+
let output = "\
737+
Name Id Version Source
738+
----------------------------------------------------------------------------------
739+
Google\x1b[2JChrome Google.Chrome 131.0 winget
740+
";
741+
let packages = backend.parse_packages_from_table(output);
742+
assert_eq!(packages.len(), 1);
743+
assert!(
744+
!packages[0].name.contains('\x1b'),
745+
"ANSI escape must be stripped from parsed package name"
746+
);
747+
}
711748
}

0 commit comments

Comments
 (0)