Skip to content

Commit 4c52ca5

Browse files
authored
fmt: fix ANSI escape sequence injection vulnerability (#3368)
Fixes a security vulnerability where ANSI escape sequences in user input could be injected into terminal output, potentially allowing attackers to manipulate terminal behavior through log messages and error displays. The vulnerability occurred when user-controlled content was formatted using Display (`{}`) instead of Debug (`{:?}`) formatting, allowing raw ANSI sequences to pass through unescaped. Changes: - Add streaming ANSI escape wrapper to avoid string allocations - Escape message content in default and pretty formatters - Escape error Display content in all error formatting paths - Add comprehensive integration tests for all formatter types The fix specifically targets untrusted user input while preserving the ability for applications to deliberately include formatting in trusted contexts like thread names. Security impact: Prevents terminal injection attacks such as title bar manipulation, screen clearing, and other malicious terminal control sequences that could be injected through log messages.
1 parent f71cebe commit 4c52ca5

File tree

6 files changed

+358
-8
lines changed

6 files changed

+358
-8
lines changed

tracing-subscriber/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
# 0.3.20 (August 29, 2025)
2+
3+
[ [crates.io][crate-0.3.20] ] | [ [docs.rs][docs-0.3.20] ]
4+
5+
### Fixed
6+
7+
- Escape ANSI escape sequences in logs
8+
19
# 0.3.19 (November 29, 2024)
210

311
[ [crates.io][crate-0.3.19] ] | [ [docs.rs][docs-0.3.19] ]

tracing-subscriber/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tracing-subscriber"
3-
version = "0.3.19"
3+
version = "0.3.20"
44
authors = [
55
"Eliza Weisman <[email protected]>",
66
"David Barsky <[email protected]>",
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//! ANSI escape sequence sanitization to prevent terminal injection attacks.
2+
3+
use std::fmt::{self, Write};
4+
5+
/// A wrapper that implements `fmt::Debug` and `fmt::Display` and escapes ANSI sequences on-the-fly.
6+
/// This avoids creating intermediate strings while providing security against terminal injection.
7+
pub(super) struct Escape<T>(pub(super) T);
8+
9+
/// Helper struct that escapes ANSI sequences as characters are written
10+
struct EscapingWriter<'a, 'b> {
11+
inner: &'a mut fmt::Formatter<'b>,
12+
}
13+
14+
impl<'a, 'b> fmt::Write for EscapingWriter<'a, 'b> {
15+
fn write_str(&mut self, s: &str) -> fmt::Result {
16+
// Stream the string character by character, escaping ANSI and C1 control sequences
17+
for ch in s.chars() {
18+
match ch {
19+
// C0 control characters that can be used in terminal escape sequences
20+
'\x1b' => self.inner.write_str("\\x1b")?, // ESC
21+
'\x07' => self.inner.write_str("\\x07")?, // BEL
22+
'\x08' => self.inner.write_str("\\x08")?, // BS
23+
'\x0c' => self.inner.write_str("\\x0c")?, // FF
24+
'\x7f' => self.inner.write_str("\\x7f")?, // DEL
25+
26+
// C1 control characters (\x80-\x9f) - 8-bit control codes
27+
// These can be used as alternative escape sequences in some terminals
28+
ch if ch as u32 >= 0x80 && ch as u32 <= 0x9f => {
29+
write!(self.inner, "\\u{{{:x}}}", ch as u32)?
30+
},
31+
32+
_ => self.inner.write_char(ch)?,
33+
}
34+
}
35+
Ok(())
36+
}
37+
}
38+
39+
impl<T: fmt::Debug> fmt::Debug for Escape<T> {
40+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41+
let mut escaping_writer = EscapingWriter { inner: f };
42+
write!(escaping_writer, "{:?}", self.0)
43+
}
44+
}
45+
46+
impl<T: fmt::Display> fmt::Display for Escape<T> {
47+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48+
let mut escaping_writer = EscapingWriter { inner: f };
49+
write!(escaping_writer, "{}", self.0)
50+
}
51+
}

tracing-subscriber/src/fmt/format/mod.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ use tracing_log::NormalizeEvent;
4848
#[cfg(feature = "ansi")]
4949
use nu_ansi_term::{Color, Style};
5050

51+
52+
mod escape;
53+
use escape::Escape;
54+
5155
#[cfg(feature = "json")]
5256
mod json;
5357
#[cfg(feature = "json")]
@@ -1257,15 +1261,15 @@ impl field::Visit for DefaultVisitor<'_> {
12571261
field,
12581262
&format_args!(
12591263
"{} {}{}{}{}",
1260-
value,
1264+
Escape(&format_args!("{}", value)),
12611265
italic.paint(field.name()),
12621266
italic.paint(".sources"),
12631267
self.writer.dimmed().paint("="),
12641268
ErrorSourceList(source)
12651269
),
12661270
)
12671271
} else {
1268-
self.record_debug(field, &format_args!("{}", value))
1272+
self.record_debug(field, &format_args!("{}", Escape(&format_args!("{}", value))))
12691273
}
12701274
}
12711275

@@ -1287,7 +1291,10 @@ impl field::Visit for DefaultVisitor<'_> {
12871291
self.maybe_pad();
12881292

12891293
self.result = match name {
1290-
"message" => write!(self.writer, "{:?}", value),
1294+
"message" => {
1295+
// Escape ANSI characters to prevent malicious patterns (e.g., terminal injection attacks)
1296+
write!(self.writer, "{:?}", Escape(value))
1297+
},
12911298
name if name.starts_with("r#") => write!(
12921299
self.writer,
12931300
"{}{}{:?}",
@@ -1326,7 +1333,7 @@ impl Display for ErrorSourceList<'_> {
13261333
let mut list = f.debug_list();
13271334
let mut curr = Some(self.0);
13281335
while let Some(curr_err) = curr {
1329-
list.entry(&format_args!("{}", curr_err));
1336+
list.entry(&Escape(&format_args!("{}", curr_err)));
13301337
curr = curr_err.source();
13311338
}
13321339
list.finish()

tracing-subscriber/src/fmt/format/pretty.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -457,15 +457,15 @@ impl field::Visit for PrettyVisitor<'_> {
457457
field,
458458
&format_args!(
459459
"{}, {}{}.sources{}: {}",
460-
value,
460+
Escape(&format_args!("{}", value)),
461461
bold.prefix(),
462462
field,
463463
bold.infix(self.style),
464464
ErrorSourceList(source),
465465
),
466466
)
467467
} else {
468-
self.record_debug(field, &format_args!("{}", value))
468+
self.record_debug(field, &Escape(&format_args!("{}", value)))
469469
}
470470
}
471471

@@ -475,7 +475,10 @@ impl field::Visit for PrettyVisitor<'_> {
475475
}
476476
let bold = self.bold();
477477
match field.name() {
478-
"message" => self.write_padded(&format_args!("{}{:?}", self.style.prefix(), value,)),
478+
"message" => {
479+
// Escape ANSI characters to prevent malicious patterns (e.g., terminal injection attacks)
480+
self.write_padded(&format_args!("{}{:?}", self.style.prefix(), Escape(value)))
481+
},
479482
// Skip fields that are actually log metadata that have already been handled
480483
#[cfg(feature = "tracing-log")]
481484
name if name.starts_with("log.") => self.result = Ok(()),

0 commit comments

Comments
 (0)