dig_nat/safe_text.rs
1//! [`SafeText`] — text that is safe to render into a log line or an error message, and the
2//! serde-decode describer that keeps a stranger's bytes out of one entirely (#1674, #1609).
3//!
4//! # Why this is a type and not a function
5//!
6//! The peer stack kept re-learning the same lesson: sanitizing at the place where text is *logged*
7//! is not sanitizing, because the next log line starts again from a raw `String`. A `String`-typed
8//! error field is an open invitation — it accepts anything, so the safety of the whole crate rests
9//! on every present and future caller remembering a convention.
10//!
11//! `SafeText` closes that by construction. It wraps a `String` that no caller can reach or supply
12//! directly: the only doors in are [`SafeText::from_untrusted`], which sanitizes on the way through,
13//! and [`SafeText::from_static`], which accepts only a `&'static str` — in practice a literal
14//! written in source, not a value a peer sent. An error variant that HOLDS a `SafeText` therefore
15//! cannot carry raw peer text however it is constructed, so the defect is unrepresentable rather
16//! than guarded.
17//!
18//! # Two different harms, two different tools
19//!
20//! * **Log injection** — peer text carrying newlines or bidi overrides forges log lines.
21//! [`SafeText::from_untrusted`] closes this: the text is still readable and diagnosable, it is
22//! just guaranteed to be one line, bounded in length.
23//! * **Information disclosure** — peer text echoed back to an operator at all, however inert.
24//! Sanitizing does NOT close this, because the stranger's content is still there. For the one
25//! place it matters most — `serde_json`, whose messages quote the offending input back —
26//! [`SafeText::describing_json_error`] rebuilds the diagnosis from the error's *structure* (its
27//! category and position) so none of the input is present to begin with.
28//!
29//! The sanitizing rules here are deliberately identical to `dig-download`'s
30//! `sanitize_untrusted_text`, which established them; this module is where the peer stack shares one
31//! copy instead of accumulating a fourth.
32
33use std::fmt;
34
35/// The character bound applied to untrusted text. Long enough for a nested dial/transport chain,
36/// short enough that a hostile peer cannot flood a log line.
37pub const MAX_SAFE_TEXT_CHARS: usize = 512;
38
39/// Text that is safe to render into a log line or an error message.
40///
41/// Guaranteed to contain no control characters and no bidirectional-formatting overrides, and to be
42/// bounded by [`MAX_SAFE_TEXT_CHARS`] characters. See the [module docs](self) for why this is a type.
43///
44/// ```
45/// use dig_nat::SafeText;
46///
47/// // A stranger's newline cannot become a line break in your log.
48/// let hostile = SafeText::from_untrusted("ok\nERROR forged");
49/// assert_eq!(hostile.as_str(), "ok\\nERROR forged");
50///
51/// // Your own literals pass through untouched.
52/// assert_eq!(SafeText::from_static("no route to peer").as_str(), "no route to peer");
53/// ```
54#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
55pub struct SafeText(String);
56
57impl SafeText {
58 /// Accept text this crate wrote itself.
59 ///
60 /// The `&'static str` bound is the check: a message a peer sent arrives in a heap `String` with
61 /// a runtime lifetime and cannot be passed here, so this door stays open for source literals
62 /// without also being open to the wire.
63 pub fn from_static(literal: &'static str) -> Self {
64 SafeText(literal.to_owned())
65 }
66
67 /// Accept text of unknown provenance, neutralizing it on the way in.
68 ///
69 /// Control characters and bidi-formatting characters are ESCAPED rather than deleted, so the
70 /// text stays diagnosable while becoming exactly one line, and the result is truncated to
71 /// [`MAX_SAFE_TEXT_CHARS`].
72 ///
73 /// This closes log injection. It does NOT make the content trustworthy or non-attacker-chosen —
74 /// if the text came from a peer, a reader still sees what the peer chose. Where that itself is
75 /// the problem, do not echo the input at all: see [`Self::describing_json_error`].
76 pub fn from_untrusted(text: impl AsRef<str>) -> Self {
77 SafeText(sanitize(text.as_ref(), MAX_SAFE_TEXT_CHARS))
78 }
79
80 /// Describe a `serde_json` decode failure **without quoting the input that caused it**.
81 ///
82 /// `serde_json`'s own message embeds the offending value verbatim (`invalid type: string
83 /// "…", expected u64`, ``unknown variant `…` ``). That is a gift to a developer and a channel
84 /// to a stranger, so on a peer wire the message is rebuilt from what the error KNOWS rather than
85 /// from what it was given: the failure category plus the line and column. A developer still
86 /// learns whether the frame was malformed JSON or well-formed JSON of the wrong shape, and
87 /// exactly where — which is what actually helps — while the peer's bytes never enter the string.
88 ///
89 /// ```
90 /// use dig_nat::SafeText;
91 ///
92 /// let err = serde_json::from_str::<u64>("\"secret-from-a-stranger\"").unwrap_err();
93 /// let described = SafeText::describing_json_error(&err);
94 ///
95 /// assert!(!described.as_str().contains("secret-from-a-stranger"));
96 /// assert!(described.as_str().contains("line 1"));
97 /// ```
98 pub fn describing_json_error(error: &serde_json::Error) -> Self {
99 use serde_json::error::Category;
100 let what = match error.classify() {
101 Category::Io => "i/o error while reading",
102 Category::Syntax => "syntax error: not well-formed",
103 Category::Data => "data error: well-formed but not the expected shape",
104 Category::Eof => "unexpected end of input",
105 };
106 SafeText(format!(
107 "{what} (at line {}, column {}; the offending input is withheld because it is \
108 peer-supplied)",
109 error.line(),
110 error.column()
111 ))
112 }
113
114 /// The sanitized text.
115 pub fn as_str(&self) -> &str {
116 &self.0
117 }
118}
119
120impl fmt::Display for SafeText {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 f.write_str(&self.0)
123 }
124}
125
126/// Renders exactly like [`Display`](fmt::Display), on purpose.
127///
128/// `Debug` reaches a log at least as often as `Display` does — `{:?}` on a `Result`, an `unwrap`
129/// panic, a `tracing` field — so a derived `Debug` that dumped the inner `String` would reopen the
130/// hole a careful `Display` had just closed. The inner value is already safe, so there is nothing to
131/// gain from a second rendering of it and everything to lose from a leaky one.
132impl fmt::Debug for SafeText {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 write!(f, "{:?}", self.0)
135 }
136}
137
138impl From<&'static str> for SafeText {
139 fn from(literal: &'static str) -> Self {
140 SafeText::from_static(literal)
141 }
142}
143
144/// Escape every control and bidi-formatting character in `text` and bound it to `max_chars`.
145///
146/// Escaping rather than deleting keeps the text diagnosable, and `escape_debug` emits one glyph per
147/// character so the bound survives substitution. Truncation is counted in CHARACTERS of the input,
148/// which is what a flood bound cares about.
149fn sanitize(text: &str, max_chars: usize) -> String {
150 let mut out = String::with_capacity(text.len().min(max_chars));
151 for (count, ch) in text.chars().enumerate() {
152 if count == max_chars {
153 out.push_str("…<truncated>");
154 break;
155 }
156 if ch.is_control() || is_bidi_control(ch) {
157 out.extend(ch.escape_debug());
158 } else {
159 out.push(ch);
160 }
161 }
162 out
163}
164
165/// Whether `ch` is a Unicode bidirectional-formatting character (the LRE/RLE/PDF/LRO/RLO overrides,
166/// the isolate set, and the LRM/RLM/ALM marks).
167///
168/// These are category-`Cf`, NOT control characters, so `is_control` misses them — yet they visually
169/// REORDER the text around them, which is enough to make a rendered log line read as something other
170/// than what it says (the classic `…exe.txt` / `…txt.exe` swap).
171fn is_bidi_control(ch: char) -> bool {
172 matches!(
173 ch,
174 '\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
175 )
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn a_newline_cannot_survive_as_a_line_break() {
184 let text = SafeText::from_untrusted("benign\n2026-07-31 ERROR forged");
185 assert!(!text.as_str().contains('\n'));
186 assert!(text.as_str().contains("forged"), "escaped, not deleted");
187 }
188
189 #[test]
190 fn a_carriage_return_and_a_nul_are_escaped_too() {
191 let text = SafeText::from_untrusted("a\r\0b");
192 assert!(!text.as_str().contains('\r'));
193 assert!(!text.as_str().contains('\0'));
194 }
195
196 /// Bidi overrides are category-`Cf`, so `char::is_control` alone would let them through.
197 #[test]
198 fn bidi_overrides_are_neutralized() {
199 for ch in ['\u{202E}', '\u{2066}', '\u{200F}', '\u{061C}'] {
200 let text = SafeText::from_untrusted(format!("safe{ch}txt"));
201 assert!(
202 !text.as_str().contains(ch),
203 "{ch:?} reached the output and can reorder the line"
204 );
205 }
206 }
207
208 /// UPPER PIN: at the bound, nothing is truncated.
209 #[test]
210 fn text_at_the_bound_is_kept_whole() {
211 let text = SafeText::from_untrusted("x".repeat(MAX_SAFE_TEXT_CHARS));
212 assert_eq!(text.as_str().chars().count(), MAX_SAFE_TEXT_CHARS);
213 assert!(!text.as_str().contains("truncated"));
214 }
215
216 /// LOWER PIN: one character past the bound IS truncated. Together with the test above exactly
217 /// one value of the bound passes, so it cannot drift.
218 #[test]
219 fn one_character_past_the_bound_is_truncated() {
220 let text = SafeText::from_untrusted("x".repeat(MAX_SAFE_TEXT_CHARS + 1));
221 assert!(text.as_str().contains("truncated"));
222 assert_eq!(text.as_str().matches('x').count(), MAX_SAFE_TEXT_CHARS);
223 }
224
225 /// Escaping expands a character into several, so a hostile flood of control characters must not
226 /// be able to multiply its way past the bound.
227 #[test]
228 fn escaping_cannot_multiply_a_flood_past_the_bound() {
229 let text = SafeText::from_untrusted("\n".repeat(MAX_SAFE_TEXT_CHARS * 4));
230 // Two glyphs per escaped newline, plus the truncation marker — bounded either way, and
231 // nowhere near the 2,048 characters the raw input asked for.
232 assert!(text.as_str().chars().count() <= MAX_SAFE_TEXT_CHARS * 2 + 16);
233 }
234
235 #[test]
236 fn debug_does_not_leak_what_display_neutralized() {
237 let text = SafeText::from_untrusted("a\nb");
238 assert!(!format!("{text:?}").contains('\n'));
239 }
240
241 #[test]
242 fn a_json_error_describes_the_failure_without_quoting_the_input() {
243 let err = serde_json::from_str::<u64>(r#""stranger-supplied-value""#)
244 .expect_err("a string is not a u64");
245 let described = SafeText::describing_json_error(&err);
246
247 assert!(
248 err.to_string().contains("stranger-supplied-value"),
249 "premise: serde echoes"
250 );
251 assert!(!described.as_str().contains("stranger-supplied-value"));
252 assert!(described.as_str().contains("data error"));
253 assert!(described.as_str().contains("line 1"));
254 }
255
256 #[test]
257 fn a_json_error_distinguishes_malformed_from_wrong_shape() {
258 // `@` is not a legal start to any JSON value, so this fails as SYNTAX. Note that an input
259 // like `{not json` does NOT: serde sees a well-formed-enough map opening and reports a DATA
260 // error ("invalid type: map, expected u64") before it ever reaches the malformed part.
261 let syntax = serde_json::from_str::<u64>("@@@").expect_err("malformed");
262 let data = serde_json::from_str::<u64>(r#""7""#).expect_err("wrong shape");
263
264 assert!(SafeText::describing_json_error(&syntax)
265 .as_str()
266 .contains("syntax error"));
267 assert!(SafeText::describing_json_error(&data)
268 .as_str()
269 .contains("data error"));
270 }
271}