lightning-types: replace Zl/Zp separators in `PrintableString`
What changed, and why it matters
This commit fixes a log-forgery risk in a Rust Lightning library helper called PrintableString. That helper is meant to make untrusted text safe to print by replacing dangerous characters with a placeholder. It already caught most control characters, but it missed two special Unicode line-break characters (U+2028 and U+2029). Because many terminals and log viewers treat those as real line breaks, an attacker could smuggle them inside an otherwise 'sanitised' string to fake new log lines, potentially misleading an operator. The patch adds a check for those two characters and includes a regression test.
Review all downstream uses of PrintableString to confirm no other output path bypasses the new predicate, and ensure the regenerated unicode.rs tables are committed consistently. Consider auditing other string-display helpers for similar separator bypasses.
Security signals we found
log injection / log forgery via U+2028/U+2029 line separators
incomplete input sanitisation in PrintableString
peer-controlled strings (node alias, BOLT 12 description/issuer/payer_note, peer_msg) as attack surface
regression test added for the sanitisation gap
Evidence from the diff
PrintableString in lightning-types sanitises Cc/Cf/Cs/Co/Cn codepoints via char::is_control and generated Unicode tables, but Zl (Line Separator, U+2028) and Zp (Paragraph Separator, U+2029) are in the Z top-level category, not C, and char::is_control only covers Cc. The patch extends the Unicode table generator to emit a new is_unicode_general_category_separator predicate covering Zl/Zp (excluding Zs so ordinary spaces remain), regenerates unicode.rs, and filters on that predicate in PrintableString. A regression test verifies both separators are replaced with U+FFFD.
Changed components
lightning-types/src/string.rslightning-types/src/unicode.rscontrib/gen_unicode_general_category.pyInspect captured patch +63 / −8
diff --git a/contrib/gen_unicode_general_category.py b/contrib/gen_unicode_general_category.py
index 4871e96..28922e2 100755
--- a/contrib/gen_unicode_general_category.py
+++ b/contrib/gen_unicode_general_category.py
@@ -10,11 +10,15 @@
"""Generate Unicode general-category predicates from `UnicodeData.txt`.
-Emits two `pub(crate)` functions taking a `char`, split into two disjoint
-buckets across the Unicode top-level `C` ("Other") category so callers can
-compose them:
+Emits three `pub(crate)` functions taking a `char`, split into disjoint
+buckets across the Unicode top-level `C` ("Other") and `Z` ("Separator")
+categories so callers can compose them:
is_unicode_general_category_other — Cc / Cf / Cs / Co (assigned)
+ is_unicode_general_category_separator — Zl / Zp (line and paragraph
+ separators; `Zs` is
+ excluded as it contains
+ U+0020 SPACE)
is_unicode_general_category_unassigned — Cn (plus codepoints above
U+10FFFF, which aren't
valid codepoints at all)
@@ -126,6 +130,7 @@ def parse_categories(path):
ASSIGNED_OTHER_CATS = frozenset({"Cc", "Cf", "Cs", "Co"})
+SEPARATOR_CATS = frozenset({"Zl", "Zp"})
def coalesce_ranges(cats, names, target_cats, *, label):
@@ -238,10 +243,11 @@ def _emit_matches_body(lines, arms):
lines.append("\t)")
-def render_rust(other_ranges, unassigned_ranges):
- """Render the final Rust source defining both `char`-taking predicates.
+def render_rust(other_ranges, separator_ranges, unassigned_ranges):
+ """Render the final Rust source defining the `char`-taking predicates.
- `other_ranges` and `unassigned_ranges` are lists of `(start, end, label)`.
+ `other_ranges`, `separator_ranges`, and `unassigned_ranges` are lists of
+ `(start, end, label)`.
The unassigned function additionally gets a synthetic final arm catching
`u32` values above U+10FFFF — these aren't valid Unicode codepoints, so
by definition they have no general category and the unassigned bucket is
@@ -262,6 +268,19 @@ def render_rust(other_ranges, unassigned_ranges):
lines.append("}")
lines.append("")
+ lines.append("/// Returns `true` if `c` is in Unicode general category `Zl` (Line")
+ lines.append("/// Separator) or `Zp` (Paragraph Separator). Terminals and log viewers")
+ lines.append("/// commonly render these as hard line breaks, so untrusted strings filter")
+ lines.append("/// them alongside the `C` buckets. `Zs` (Space Separator) is deliberately")
+ lines.append("/// excluded: it contains U+0020 SPACE and other ordinary spacing")
+ lines.append("/// characters.")
+ lines.append("#[allow(dead_code)]")
+ lines.append("pub(crate) fn is_unicode_general_category_separator(c: char) -> bool {")
+ separator_arms = [(_pattern(s, e), label) for s, e, label in separator_ranges]
+ _emit_matches_body(lines, separator_arms)
+ lines.append("}")
+ lines.append("")
+
lines.append("/// Returns `true` if `c` is in Unicode general category `Cn` (Unassigned), or")
lines.append("/// strictly above U+10FFFF. The trailing `0x110000..=u32::MAX` arm is")
lines.append("/// unreachable for `char` input (a `char` is bounded to U+10FFFF) but is kept")
@@ -289,8 +308,9 @@ def main(argv):
cats, names = parse_categories(args.unicode_data)
other = coalesce_ranges(cats, names, ASSIGNED_OTHER_CATS, label=True)
+ separator = coalesce_ranges(cats, names, SEPARATOR_CATS, label=True)
unassigned = coalesce_ranges(cats, names, frozenset({"Cn"}), label=False)
- rust = render_rust(other, unassigned)
+ rust = render_rust(other, separator, unassigned)
if args.output is None:
sys.stdout.write(rust)
@@ -299,6 +319,7 @@ def main(argv):
print(
f"Wrote {args.output} "
f"({len(other)} assigned-Other ranges, "
+ f"{len(separator)} separator ranges, "
f"{len(unassigned)} unassigned ranges).",
file=sys.stderr,
)
diff --git a/lightning-types/src/string.rs b/lightning-types/src/string.rs
index a21cad4..a92c5c3 100644
--- a/lightning-types/src/string.rs
+++ b/lightning-types/src/string.rs
@@ -35,7 +35,14 @@ impl<'a> fmt::Display for PrintableString<'a> {
for c in self.0.chars() {
let is_other = is_unicode_general_category_other(c);
let is_unassigned = is_unicode_general_category_unassigned(c);
- let c = if c.is_control() || is_other || is_unassigned {
+ // U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR (general
+ // categories `Zl`/`Zp`) are covered by neither `char::is_control`
+ // (`Cc` only) nor the top-level `C` tables above, but terminals and
+ // log viewers commonly render them as hard line breaks, allowing an
+ // attacker-controlled string to inject forged lines into operator
+ // logs — so the generated separator table filters them as well.
+ let is_line_separator = is_unicode_general_category_separator(c);
+ let c = if c.is_control() || is_other || is_unassigned || is_line_separator {
core::char::REPLACEMENT_CHARACTER
} else {
c
@@ -78,4 +85,17 @@ mod tests {
// general category is `Mn`, not `Cf`, so the `Cf` range ends at U+1343F.
assert_eq!(format!("{}", PrintableString("x\u{1343F}y\u{13440}z")), "x\u{FFFD}y\u{13440}z");
}
+
+ #[test]
+ fn sanitizes_line_and_paragraph_separators() {
+ // U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are general
+ // categories `Zl`/`Zp`, not `Cc`, so `char::is_control` does not catch
+ // them, yet terminals and log viewers commonly render them as hard line
+ // breaks. As with the bidi overrides above, an attacker-controlled string
+ // must not be able to use them to inject forged log lines.
+ assert_eq!(
+ format!("{}", PrintableString("ok\u{2028}forged\u{2029}more")),
+ "ok\u{FFFD}forged\u{FFFD}more"
+ );
+ }
}
diff --git a/lightning-types/src/unicode.rs b/lightning-types/src/unicode.rs
index 22b2196..569424c 100644
--- a/lightning-types/src/unicode.rs
+++ b/lightning-types/src/unicode.rs
@@ -50,6 +50,20 @@ pub(crate) fn is_unicode_general_category_other(c: char) -> bool {
)
}
+/// Returns `true` if `c` is in Unicode general category `Zl` (Line
+/// Separator) or `Zp` (Paragraph Separator). Terminals and log viewers
+/// commonly render these as hard line breaks, so untrusted strings filter
+/// them alongside the `C` buckets. `Zs` (Space Separator) is deliberately
+/// excluded: it contains U+0020 SPACE and other ordinary spacing
+/// characters.
+#[allow(dead_code)]
+pub(crate) fn is_unicode_general_category_separator(c: char) -> bool {
+ matches!(
+ c as u32,
+ 0x2028..=0x2029 // SEPARATOR
+ )
+}
+
/// Returns `true` if `c` is in Unicode general category `Cn` (Unassigned), or
/// strictly above U+10FFFF. The trailing `0x110000..=u32::MAX` arm is
/// unreachable for `char` input (a `char` is bounded to U+10FFFF) but is kept
Why this scored 72/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.