Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/fix-formatter-sanitize-terminal-escapes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@googleworkspace/cli": patch
---

fix(formatter): strip terminal escape sequences from non-JSON output

API responses may contain user-generated content with ANSI escape codes or
other control characters. JSON output is safe because serde escapes them as
\uXXXX, but table/CSV/YAML formats passed strings verbatim, allowing a
malicious API value to inject terminal sequences. Adds strip_control_chars()
which is applied to every string cell in value_to_cell().
165 changes: 164 additions & 1 deletion crates/google-workspace-cli/src/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ fn json_to_yaml(value: &Value, indent: usize) -> String {
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => {
let s = strip_control_chars(s);
if s.contains('\n') {
// Genuine multi-line content: block scalar is the most readable choice.
format!(
Expand Down Expand Up @@ -418,10 +419,101 @@ fn csv_escape(s: &str) -> String {
}
}

/// Strips ANSI/VT terminal escape sequences and C0/C1 control characters
/// (except `\t` and `\n`) from a string.
///
/// API responses may contain user-generated content with embedded escape codes.
/// JSON output is safe because serde serialises control characters as `\uXXXX`,
/// but table/CSV/YAML formats pass strings through verbatim, which would allow
/// a malicious API value to inject terminal sequences into the user's terminal.
///
/// Sequences handled:
/// - CSI `ESC [` … final-byte (SGR colours, cursor movement, …)
/// - OSC `ESC ]` … BEL or ST (window title, hyperlinks, …)
/// - DCS `ESC P` … ST (device-control strings)
/// - SOS `ESC X` … ST (start-of-string)
/// - PM `ESC ^` … ST (privacy message)
/// - APC `ESC _` … ST (application-program command)
/// - Other two-char Fe sequences (`ESC` + 0x40–0x5F, not in the above)
/// - Bare C0/C1 control characters (NUL, BEL, BS, CR, …; tab/newline kept)
fn strip_control_chars(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();

/// Consume chars until ST (BEL `\x07` or `ESC \`), used for OSC/DCS/SOS/PM/APC.
fn consume_until_st(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
while let Some(ch) = chars.next() {
if ch == '\x07' {
break; // BEL string terminator
}
if ch == '\x1b' {
if let Some('\\') = chars.peek() {
chars.next(); // consume the `\` of ESC \
}
break;
}
}
}

while let Some(c) = chars.next() {
match c {
// ESC-prefixed sequences: consume until the sequence terminator.
'\x1b' => {
match chars.peek().copied() {
// CSI: ESC [ … <final byte 0x40–0x7E>
Some('[') => {
chars.next();
for ch in chars.by_ref() {
if ('\x40'..='\x7e').contains(&ch) {
break;
}
}
}
// OSC: ESC ] … ST
Some(']') => {
chars.next();
consume_until_st(&mut chars);
}
// DCS: ESC P … ST
Some('P') => {
chars.next();
consume_until_st(&mut chars);
}
// SOS: ESC X … ST
Some('X') => {
chars.next();
consume_until_st(&mut chars);
}
// PM: ESC ^ … ST
Some('^') => {
chars.next();
consume_until_st(&mut chars);
}
// APC: ESC _ … ST
Some('_') => {
chars.next();
consume_until_st(&mut chars);
}
// Other Fe two-char sequences: ESC <0x40–0x5F> — consume one char.
Some(ch) if ('\x40'..='\x5f').contains(&ch) => {
chars.next();
}
_ => {}
}
}
// Allow tab and newline; strip all other C0/C1 control characters.
'\t' | '\n' => out.push(c),
c if c.is_control() => {}
c => out.push(c),
}
}
out
}

fn value_to_cell(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::String(s) => strip_control_chars(s),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Array(arr) => {
Expand Down Expand Up @@ -629,6 +721,77 @@ mod tests {
assert_eq!(csv_escape("has\"quote"), "\"has\"\"quote\"");
}

#[test]
fn test_strip_control_chars_clean_string() {
assert_eq!(strip_control_chars("hello world"), "hello world");
assert_eq!(strip_control_chars("tab\there"), "tab\there");
assert_eq!(strip_control_chars("line\nbreak"), "line\nbreak");
}

#[test]
fn test_strip_control_chars_csi_sequence() {
// SGR colour code: ESC [ 31 m
assert_eq!(strip_control_chars("\x1b[31mred\x1b[0m"), "red");
// Bold: ESC [ 1 m
assert_eq!(strip_control_chars("\x1b[1mbold\x1b[m"), "bold");
}

#[test]
fn test_strip_control_chars_osc_sequence() {
// OSC title injection: ESC ] 0 ; malicious BEL
assert_eq!(
strip_control_chars("\x1b]0;malicious\x07clean"),
"clean"
);
// OSC terminated by ESC \
assert_eq!(
strip_control_chars("\x1b]2;title\x1b\\clean"),
"clean"
);
}

#[test]
fn test_strip_control_chars_c0_control() {
// NUL, BEL, BS, CR stripped; tab and newline kept
assert_eq!(strip_control_chars("a\x00b"), "ab");
assert_eq!(strip_control_chars("a\x07b"), "ab");
assert_eq!(strip_control_chars("a\x08b"), "ab");
assert_eq!(strip_control_chars("a\rb"), "ab");
}

#[test]
fn test_strip_control_chars_dcs_sequence() {
// DCS: ESC P … ST (BEL-terminated)
assert_eq!(strip_control_chars("\x1bPdcs-payload\x07clean"), "clean");
// DCS: ESC P … ST (ESC \-terminated)
assert_eq!(strip_control_chars("\x1bPdcs-payload\x1b\\clean"), "clean");
}

#[test]
fn test_strip_control_chars_sos_pm_apc_sequences() {
// SOS: ESC X … ST
assert_eq!(strip_control_chars("\x1bXsos-payload\x07clean"), "clean");
// PM: ESC ^ … ST
assert_eq!(strip_control_chars("\x1b^pm-payload\x07clean"), "clean");
// APC: ESC _ … ST
assert_eq!(strip_control_chars("\x1b_apc-payload\x07clean"), "clean");
}

#[test]
fn test_value_to_cell_sanitizes_escape_sequences() {
let val = Value::String("\x1b[31mred\x1b[0m".to_string());
assert_eq!(value_to_cell(&val), "red");
}

#[test]
fn test_format_yaml_sanitizes_escape_sequences() {
// YAML strings must also be sanitized.
let val = json!({"title": "\x1b]0;injected\x07safe"});
let output = format_value(&val, &OutputFormat::Yaml);
assert!(output.contains("safe"));
assert!(!output.contains("\x1b"));
}

#[test]
fn test_format_yaml() {
let val = json!({"name": "test", "count": 42});
Expand Down
Loading