diff --git a/src/tls/inspect.rs b/src/tls/inspect.rs index 73eb06cd..f57da48c 100644 --- a/src/tls/inspect.rs +++ b/src/tls/inspect.rs @@ -1231,6 +1231,44 @@ TQt+xSSOMTZFrHhhVqsL9JQlHg== assert!(out.contains("SANs: example.com, 127.0.0.1")); } + #[test] + fn render_escapes_untrusted_tls_diagnostic_text() { + let inspection = Inspection { + version: Some(ProtocolVersion::TLSv1_3), + cipher_suite: CipherSuiteStatus::Unavailable, + alpn: Some("h2\x1b]0;owned\x07".to_string()), + ech_status: EchStatus::NotOffered, + chain: vec![ParsedCert { + raw: vec![1], + common_name: Some("cn\x1b\n\r\x07\u{202e}.example".to_string()), + organization: None, + dns_names: vec!["dns\u{85}\u{7f}\u{2066}\\name".to_string()], + ip_addresses: Vec::new(), + not_after: None, + issuer_der: Vec::new(), + subject_der: Vec::new(), + subject_name_der: Vec::new(), + spki_der: Vec::new(), + subject_public_key: Vec::new(), + serial_number: Vec::new(), + subject_key_id: None, + authority_key_id: None, + subject: String::new(), + }], + ocsp_response: Vec::new(), + }; + + let out = render(&inspection); + + assert!(out.contains("ALPN: h2\\x1b]0;owned\\x07")); + assert!(out.contains("cn\\x1b\\n\\r\\x07\\u{202e}.example")); + assert!(out.contains("SANs: dns\\u{85}\\x7f\\u{2066}\\\\name")); + assert!(!out.contains('\x1b')); + assert!(!out.contains('\r')); + assert!(!out.contains('\x07')); + assert!(!out.contains('\u{202e}')); + } + #[test] fn render_quic_reports_unavailable_cipher_suite() { let inspection = Inspection { diff --git a/src/tls/inspect/render.rs b/src/tls/inspect/render.rs index d77f9814..48e04046 100644 --- a/src/tls/inspect/render.rs +++ b/src/tls/inspect/render.rs @@ -1,3 +1,5 @@ +use std::fmt::Write as _; + use rustls::{ProtocolVersion, SupportedCipherSuite}; use crate::core::{Printer, Sequence}; @@ -40,7 +42,7 @@ pub(super) fn render_to(inspection: &Inspection, out: &mut Printer) { if let Some(alpn) = &inspection.alpn { out.write_info_prefix(); out.push_str("ALPN: "); - out.write_styled(alpn, &[Sequence::Italic]); + out.write_styled(&escape_untrusted_tls_text(alpn), &[Sequence::Italic]); out.push('\n'); } @@ -81,7 +83,10 @@ fn render_cert_chain(out: &mut Printer, chain: &[ParsedCert]) { out.write_info_prefix(); out.push_str(&" ".repeat(index)); out.write_styled("└─ ", &[Sequence::Dim]); - out.write_styled(&cert.display_name(), &[Sequence::Bold]); + out.write_styled( + &escape_untrusted_tls_text(&cert.display_name()), + &[Sequence::Bold], + ); let (expiry_text, expiry_color) = cert_expiry_info_and_color(cert.not_after); out.push_str(" ("); out.write_styled(&expiry_text, &[expiry_color]); @@ -99,10 +104,42 @@ fn render_sans(out: &mut Printer, cert: &ParsedCert) { out.push_str("\n"); out.write_info_prefix(); out.push_str("SANs: "); - out.write_styled(&sans.join(", "), &[Sequence::Italic]); + out.write_styled( + &escape_untrusted_tls_text(&sans.join(", ")), + &[Sequence::Italic], + ); out.push('\n'); } +/// Escape remotely supplied TLS text before writing it to diagnostics. +/// +/// Backslashes are also escaped so that the output cannot imitate an escape +/// added by this function. +fn escape_untrusted_tls_text(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + '\u{00}'..='\u{1f}' | '\u{7f}' => { + write!(escaped, "\\x{:02x}", ch as u32).expect("writing to a String cannot fail"); + } + '\u{80}'..='\u{9f}' + | '\u{061c}' + | '\u{200e}' + | '\u{200f}' + | '\u{2028}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' => { + write!(escaped, "\\u{{{:x}}}", ch as u32).expect("writing to a String cannot fail"); + } + _ => escaped.push(ch), + } + } + escaped +} + pub(super) fn render_ocsp_status( out: &mut Printer, raw_ocsp: &[u8],