Skip to content

Commit fd99729

Browse files
benthecarmanclaude
andcommitted
Sanitize CLI output against Unicode bidi control characters
Escape Unicode bidirectional override/embedding/isolate characters (U+200E–U+2069) as \uXXXX in CLI JSON output to prevent them from silently reordering displayed text in terminal emulators. serde_json already handles ASCII control characters (U+0000–U+001F), but bidi characters pass through unescaped. The sanitization is done in the CLI display layer, not the server, so the server returns raw data faithfully. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7a0569d commit fd99729

File tree

2 files changed

+96
-1
lines changed

2 files changed

+96
-1
lines changed

e2e-tests/tests/e2e.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,36 @@ async fn test_cli_decode_invoice() {
210210
run_cli(&server, &["decode-invoice", output_var["invoice"].as_str().unwrap()]);
211211
assert!(decoded_var.get("amount_msat").is_none() || decoded_var["amount_msat"].is_null());
212212
assert_eq!(decoded_var["description"], "no amount");
213+
214+
// Test that ANSI escape sequences cannot reach the terminal via CLI output.
215+
// serde_json escapes control chars (U+0000–U+001F) as \uXXXX in JSON.
216+
let desc_with_ansi = "pay me\x1b[31m RED \x1b[0m";
217+
let output_ansi = run_cli(&server, &["bolt11-receive", "-d", desc_with_ansi]);
218+
let raw_decoded = run_cli_raw(
219+
&server,
220+
&["decode-invoice", output_ansi["invoice"].as_str().unwrap()],
221+
);
222+
assert!(
223+
!raw_decoded.contains('\x1b'),
224+
"Raw CLI output must not contain ANSI escape bytes"
225+
);
226+
227+
// Test that Unicode bidi override characters in the description are escaped
228+
// (sanitize_for_terminal replaces them with \uXXXX in CLI output)
229+
let desc_with_bidi = "pay me\u{202E}evil";
230+
let output_bidi = run_cli(&server, &["bolt11-receive", "-d", desc_with_bidi]);
231+
let raw_bidi = run_cli_raw(
232+
&server,
233+
&["decode-invoice", output_bidi["invoice"].as_str().unwrap()],
234+
);
235+
assert!(
236+
!raw_bidi.contains('\u{202E}'),
237+
"Raw CLI output must not contain bidi override characters"
238+
);
239+
assert!(
240+
raw_bidi.contains("\\u202E"),
241+
"Bidi characters should be escaped as \\uXXXX in output"
242+
);
213243
}
214244

215245
#[tokio::test]
@@ -273,6 +303,34 @@ async fn test_cli_decode_offer() {
273303
let decoded_fixed =
274304
run_cli(&server_a, &["decode-offer", output_fixed["offer"].as_str().unwrap()]);
275305
assert_eq!(decoded_fixed["amount"]["amount"]["bitcoin_amount_msats"], 50_000_000);
306+
307+
// Test that ANSI escape sequences cannot reach the terminal via CLI output.
308+
let desc_with_ansi = "offer\x1b[31m RED \x1b[0m";
309+
let output_ansi = run_cli(&server_a, &["bolt12-receive", desc_with_ansi]);
310+
let raw_decoded = run_cli_raw(
311+
&server_a,
312+
&["decode-offer", output_ansi["offer"].as_str().unwrap()],
313+
);
314+
assert!(
315+
!raw_decoded.contains('\x1b'),
316+
"Raw CLI output must not contain ANSI escape bytes"
317+
);
318+
319+
// Test that Unicode bidi override characters in the description are escaped
320+
let desc_with_bidi = "offer\u{202E}evil";
321+
let output_bidi = run_cli(&server_a, &["bolt12-receive", desc_with_bidi]);
322+
let raw_bidi = run_cli_raw(
323+
&server_a,
324+
&["decode-offer", output_bidi["offer"].as_str().unwrap()],
325+
);
326+
assert!(
327+
!raw_bidi.contains('\u{202E}'),
328+
"Raw CLI output must not contain bidi override characters"
329+
);
330+
assert!(
331+
raw_bidi.contains("\\u202E"),
332+
"Bidi characters should be escaped as \\uXXXX in output"
333+
);
276334
}
277335

278336
#[tokio::test]

ldk-server-cli/src/main.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// You may not use this file except in accordance with one or both of these
88
// licenses.
99

10+
use std::fmt::Write;
1011
use std::path::PathBuf;
1112

1213
use clap::{CommandFactory, Parser, Subcommand};
@@ -1165,6 +1166,42 @@ where
11651166
}
11661167
}
11671168

1169+
/// Escapes Unicode bidirectional control characters as `\uXXXX` so they are visible
1170+
/// in terminal output rather than silently reordering displayed text.
1171+
/// serde_json already escapes ASCII control characters (U+0000–U+001F), but bidi
1172+
/// overrides (U+200E–U+2069) pass through unescaped.
1173+
fn sanitize_for_terminal(s: &str) -> String {
1174+
fn is_bidi_control(c: char) -> bool {
1175+
matches!(
1176+
c,
1177+
'\u{200E}' // LEFT-TO-RIGHT MARK
1178+
| '\u{200F}' // RIGHT-TO-LEFT MARK
1179+
| '\u{202A}' // LEFT-TO-RIGHT EMBEDDING
1180+
| '\u{202B}' // RIGHT-TO-LEFT EMBEDDING
1181+
| '\u{202C}' // POP DIRECTIONAL FORMATTING
1182+
| '\u{202D}' // LEFT-TO-RIGHT OVERRIDE
1183+
| '\u{202E}' // RIGHT-TO-LEFT OVERRIDE
1184+
| '\u{2066}' // LEFT-TO-RIGHT ISOLATE
1185+
| '\u{2067}' // RIGHT-TO-LEFT ISOLATE
1186+
| '\u{2068}' // FIRST STRONG ISOLATE
1187+
| '\u{2069}' // POP DIRECTIONAL ISOLATE
1188+
)
1189+
}
1190+
if s.chars().any(is_bidi_control) {
1191+
let mut out = String::with_capacity(s.len());
1192+
for c in s.chars() {
1193+
if is_bidi_control(c) {
1194+
write!(out, "\\u{:04X}", c as u32).unwrap();
1195+
} else {
1196+
out.push(c);
1197+
}
1198+
}
1199+
out
1200+
} else {
1201+
s.to_string()
1202+
}
1203+
}
1204+
11681205
fn handle_response_result<Rs, Js>(response: Result<Rs, LdkServerError>)
11691206
where
11701207
Rs: Into<Js>,
@@ -1174,7 +1211,7 @@ where
11741211
Ok(response) => {
11751212
let json_response: Js = response.into();
11761213
match serde_json::to_string_pretty(&json_response) {
1177-
Ok(json) => println!("{json}"),
1214+
Ok(json) => println!("{}", sanitize_for_terminal(&json)),
11781215
Err(e) => {
11791216
eprintln!("Error serializing response ({json_response:?}) to JSON: {e}");
11801217
std::process::exit(1);

0 commit comments

Comments
 (0)