Skip to main content

parley_markup/
lib.rs

1//! Renders untrusted markdown into a typed tree that cannot carry markup.
2//!
3//! Message bodies are attacker-controlled. In the CLI they land in a
4//! terminal; in a webview they land somewhere that can execute code, and
5//! a sender who achieves script execution there is inside the trust
6//! boundary and can read and exfiltrate every conversation. That is
7//! strictly worse than compromising the relay, which only ever sees
8//! ciphertext.
9//!
10//! So this crate never produces an HTML string. It produces [`Node`] and
11//! [`Inline`] values that a renderer maps to elements, which means there
12//! is no string for an injection to hide in and no `innerHTML` at the far
13//! end. Three rules do the work:
14//!
15//! 1. **Raw HTML is text, not markup.** `pulldown-cmark` emits embedded
16//!    HTML as its own event; those become [`Inline::Text`] verbatim. A
17//!    body containing `<script>` renders those characters on screen.
18//! 2. **Link targets are allowlisted** to `http`, `https`, and `mailto`.
19//!    Anything else (`javascript:`, `data:`, `vbscript:`, `file:`) loses
20//!    its link and keeps its text, so the content survives and the
21//!    navigation does not.
22//! 3. **Remote images are not fetched.** This is privacy, not just
23//!    safety: an inlined remote image is a read receipt telling the
24//!    sender when you opened a message and from which IP. They arrive as
25//!    [`ImageSource::Blocked`] for the UI to offer as click-to-load.
26
27use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
28use serde::{Deserialize, Serialize};
29
30/// Schemes a link may navigate to. Everything else is stripped.
31const ALLOWED_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum Node {
36    Paragraph {
37        content: Vec<Inline>,
38    },
39    Heading {
40        level: u8,
41        content: Vec<Inline>,
42    },
43    List {
44        ordered: bool,
45        items: Vec<Vec<Node>>,
46    },
47    CodeBlock {
48        language: Option<String>,
49        code: String,
50    },
51    BlockQuote {
52        content: Vec<Node>,
53    },
54    Rule,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum Inline {
60    Text {
61        text: String,
62    },
63    Code {
64        code: String,
65    },
66    Emphasis {
67        content: Vec<Inline>,
68    },
69    Strong {
70        content: Vec<Inline>,
71    },
72    Strikethrough {
73        content: Vec<Inline>,
74    },
75    /// A link whose target already passed the scheme allowlist.
76    Link {
77        href: String,
78        content: Vec<Inline>,
79    },
80    Image {
81        source: ImageSource,
82        alt: String,
83    },
84    SoftBreak,
85    HardBreak,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(tag = "kind", rename_all = "snake_case")]
90pub enum ImageSource {
91    /// Already on disk and end-to-end encrypted in transit: a received
92    /// file. Safe to render inline; fetching it tells nobody anything.
93    Local { path: String },
94    /// Off-machine. Never fetched automatically, because loading it would
95    /// report back to whoever controls that host. Retained so a UI can
96    /// offer an explicit click-to-load.
97    Blocked { url: String },
98}
99
100/// Parse `markdown` into a tree that carries no markup.
101#[must_use]
102pub fn render(markdown: &str) -> Vec<Node> {
103    let mut options = Options::empty();
104    options.insert(Options::ENABLE_STRIKETHROUGH);
105    options.insert(Options::ENABLE_TABLES);
106    options.insert(Options::ENABLE_FOOTNOTES);
107    // Deliberately NOT enabling any option that widens what the parser
108    // will treat as markup.
109
110    let mut blocks = Vec::new();
111    let mut parser = Parser::new_ext(markdown, options).peekable();
112    while let Some(event) = parser.next() {
113        if let Some(node) = block(&mut parser, event) {
114            blocks.push(node);
115        }
116    }
117    blocks
118}
119
120type EventStream<'a> = std::iter::Peekable<Parser<'a>>;
121
122fn block(p: &mut EventStream<'_>, event: Event<'_>) -> Option<Node> {
123    match event {
124        Event::Start(Tag::Paragraph) => Some(Node::Paragraph {
125            content: inlines(p, TagEnd::Paragraph),
126        }),
127        Event::Start(Tag::Heading { level, .. }) => Some(Node::Heading {
128            level: heading_level(level),
129            content: inlines(p, TagEnd::Heading(level)),
130        }),
131        Event::Start(Tag::CodeBlock(kind)) => {
132            let language = match kind {
133                pulldown_cmark::CodeBlockKind::Fenced(l) if !l.is_empty() => Some(l.to_string()),
134                _ => None,
135            };
136            let mut code = String::new();
137            for e in p.by_ref() {
138                match e {
139                    // Inside a fence everything is literal, including
140                    // anything that looks like a tag.
141                    Event::Text(t) | Event::Html(t) | Event::InlineHtml(t) => code.push_str(&t),
142                    Event::End(TagEnd::CodeBlock) => break,
143                    _ => {}
144                }
145            }
146            Some(Node::CodeBlock { language, code })
147        }
148        Event::Start(Tag::BlockQuote(_)) => {
149            let mut content = Vec::new();
150            while let Some(e) = p.next() {
151                if matches!(e, Event::End(TagEnd::BlockQuote(_))) {
152                    break;
153                }
154                if let Some(n) = block(p, e) {
155                    content.push(n);
156                }
157            }
158            Some(Node::BlockQuote { content })
159        }
160        Event::Start(Tag::List(start)) => {
161            let ordered = start.is_some();
162            let mut items = Vec::new();
163            while let Some(e) = p.next() {
164                match e {
165                    Event::Start(Tag::Item) => {
166                        let mut item = Vec::new();
167                        while let Some(ie) = p.next() {
168                            if matches!(ie, Event::End(TagEnd::Item)) {
169                                break;
170                            }
171                            // A bare item body arrives as loose inlines
172                            // rather than a paragraph; wrap it so items
173                            // always hold blocks.
174                            if let Some(n) = block(p, ie.clone()) {
175                                item.push(n);
176                            } else if let Some(inline) = inline_of(&ie) {
177                                match item.last_mut() {
178                                    Some(Node::Paragraph { content }) => content.push(inline),
179                                    _ => item.push(Node::Paragraph {
180                                        content: vec![inline],
181                                    }),
182                                }
183                            }
184                        }
185                        items.push(item);
186                    }
187                    Event::End(TagEnd::List(_)) => break,
188                    _ => {}
189                }
190            }
191            Some(Node::List { ordered, items })
192        }
193        Event::Rule => Some(Node::Rule),
194        // A stray inline at block level (common for one-line bodies).
195        other => inline_of(&other).map(|i| Node::Paragraph { content: vec![i] }),
196    }
197}
198
199fn inlines(p: &mut EventStream<'_>, until: TagEnd) -> Vec<Inline> {
200    let mut out = Vec::new();
201    while let Some(e) = p.next() {
202        if e == Event::End(until) {
203            break;
204        }
205        match e {
206            Event::Start(Tag::Emphasis) => out.push(Inline::Emphasis {
207                content: inlines(p, TagEnd::Emphasis),
208            }),
209            Event::Start(Tag::Strong) => out.push(Inline::Strong {
210                content: inlines(p, TagEnd::Strong),
211            }),
212            Event::Start(Tag::Strikethrough) => out.push(Inline::Strikethrough {
213                content: inlines(p, TagEnd::Strikethrough),
214            }),
215            Event::Start(Tag::Link { dest_url, .. }) => {
216                let content = inlines(p, TagEnd::Link);
217                match sanitize_href(&dest_url) {
218                    // Target rejected: keep what it said, drop where it went.
219                    None => out.extend(content),
220                    Some(href) => out.push(Inline::Link { href, content }),
221                }
222            }
223            Event::Start(Tag::Image { dest_url, .. }) => {
224                let alt = plain_text(&inlines(p, TagEnd::Image));
225                out.push(Inline::Image {
226                    source: image_source(&dest_url),
227                    alt,
228                });
229            }
230            other => {
231                if let Some(i) = inline_of(&other) {
232                    out.push(i);
233                }
234            }
235        }
236    }
237    out
238}
239
240fn inline_of(e: &Event<'_>) -> Option<Inline> {
241    match e {
242        Event::Text(t) => Some(Inline::Text {
243            text: t.to_string(),
244        }),
245        Event::Code(c) => Some(Inline::Code {
246            code: c.to_string(),
247        }),
248        // The load-bearing case: embedded HTML becomes literal text, so
249        // `<script>` is something you read, not something that runs.
250        Event::Html(h) | Event::InlineHtml(h) => Some(Inline::Text {
251            text: h.to_string(),
252        }),
253        Event::SoftBreak => Some(Inline::SoftBreak),
254        Event::HardBreak => Some(Inline::HardBreak),
255        _ => None,
256    }
257}
258
259/// Accept a link target only if its scheme is allowlisted, or if it has
260/// no scheme at all (a relative or fragment link, which cannot navigate
261/// off-origin from a bundled page).
262fn sanitize_href(raw: &str) -> Option<String> {
263    let trimmed = raw.trim();
264    if trimmed.is_empty() {
265        return None;
266    }
267    // Control characters (including NUL and newlines) are how
268    // `java\0script:` style bypasses are smuggled past naive checks.
269    if trimmed.chars().any(char::is_control) {
270        return None;
271    }
272    match scheme_of(trimmed) {
273        None => Some(trimmed.to_owned()),
274        Some(scheme) => ALLOWED_SCHEMES
275            .contains(&scheme.as_str())
276            .then(|| trimmed.to_owned()),
277    }
278}
279
280/// The scheme of `s`, lowercased, if it has one. A colon appearing after
281/// a `/`, `?`, or `#` belongs to a path, not a scheme.
282fn scheme_of(s: &str) -> Option<String> {
283    let end = s.find(':')?;
284    let candidate = &s[..end];
285    if candidate.is_empty() || s[..end].contains(['/', '?', '#']) {
286        return None;
287    }
288    // Per RFC 3986 a scheme is ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ).
289    let mut chars = candidate.chars();
290    let first = chars.next()?;
291    if !first.is_ascii_alphabetic() {
292        return None;
293    }
294    if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
295        return None;
296    }
297    Some(candidate.to_ascii_lowercase())
298}
299
300fn image_source(raw: &str) -> ImageSource {
301    let trimmed = raw.trim();
302    match scheme_of(trimmed) {
303        // No scheme: a path to something already on disk.
304        None => ImageSource::Local {
305            path: trimmed.to_owned(),
306        },
307        Some(s) if s == "file" => ImageSource::Local {
308            path: trimmed.to_owned(),
309        },
310        _ => ImageSource::Blocked {
311            url: trimmed.to_owned(),
312        },
313    }
314}
315
316fn plain_text(inlines: &[Inline]) -> String {
317    let mut s = String::new();
318    for i in inlines {
319        match i {
320            Inline::Text { text } => s.push_str(text),
321            Inline::Code { code } => s.push_str(code),
322            Inline::Emphasis { content }
323            | Inline::Strong { content }
324            | Inline::Strikethrough { content }
325            | Inline::Link { content, .. } => s.push_str(&plain_text(content)),
326            Inline::SoftBreak | Inline::HardBreak => s.push(' '),
327            Inline::Image { alt, .. } => s.push_str(alt),
328        }
329    }
330    s
331}
332
333fn heading_level(l: HeadingLevel) -> u8 {
334    match l {
335        HeadingLevel::H1 => 1,
336        HeadingLevel::H2 => 2,
337        HeadingLevel::H3 => 3,
338        HeadingLevel::H4 => 4,
339        HeadingLevel::H5 => 5,
340        HeadingLevel::H6 => 6,
341    }
342}
343
344#[cfg(test)]
345#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
346mod tests {
347    use super::*;
348
349    /// Flatten to the text a reader would see, so assertions can check
350    /// that content survived without depending on tree shape.
351    fn visible(nodes: &[Node]) -> String {
352        fn walk(nodes: &[Node], out: &mut String) {
353            for n in nodes {
354                match n {
355                    Node::Paragraph { content } | Node::Heading { content, .. } => {
356                        out.push_str(&plain_text(content));
357                        out.push('\n');
358                    }
359                    Node::CodeBlock { code, .. } => out.push_str(code),
360                    Node::BlockQuote { content } => walk(content, out),
361                    Node::List { items, .. } => {
362                        for i in items {
363                            walk(i, out);
364                        }
365                    }
366                    Node::Rule => out.push('\n'),
367                }
368            }
369        }
370        let mut s = String::new();
371        walk(nodes, &mut s);
372        s
373    }
374
375    fn links(nodes: &[Node]) -> Vec<String> {
376        fn walk_inline(is: &[Inline], out: &mut Vec<String>) {
377            for i in is {
378                match i {
379                    Inline::Link { href, content } => {
380                        out.push(href.clone());
381                        walk_inline(content, out);
382                    }
383                    Inline::Emphasis { content }
384                    | Inline::Strong { content }
385                    | Inline::Strikethrough { content } => walk_inline(content, out),
386                    _ => {}
387                }
388            }
389        }
390        fn walk(nodes: &[Node], out: &mut Vec<String>) {
391            for n in nodes {
392                match n {
393                    Node::Paragraph { content } | Node::Heading { content, .. } => {
394                        walk_inline(content, out);
395                    }
396                    Node::BlockQuote { content } => walk(content, out),
397                    Node::List { items, .. } => {
398                        for i in items {
399                            walk(i, out);
400                        }
401                    }
402                    _ => {}
403                }
404            }
405        }
406        let mut v = Vec::new();
407        walk(nodes, &mut v);
408        v
409    }
410
411    fn images(nodes: &[Node]) -> Vec<ImageSource> {
412        let mut out = Vec::new();
413        fn walk_inline(is: &[Inline], out: &mut Vec<ImageSource>) {
414            for i in is {
415                match i {
416                    Inline::Image { source, .. } => out.push(source.clone()),
417                    Inline::Emphasis { content }
418                    | Inline::Strong { content }
419                    | Inline::Strikethrough { content }
420                    | Inline::Link { content, .. } => walk_inline(content, out),
421                    _ => {}
422                }
423            }
424        }
425        for n in nodes {
426            if let Node::Paragraph { content } | Node::Heading { content, .. } = n {
427                walk_inline(content, &mut out);
428            }
429        }
430        out
431    }
432
433    #[test]
434    fn ordinary_markdown_still_formats() {
435        let nodes = render("# Title\n\nSome **bold** and `code`.\n\n- one\n- two\n");
436        assert!(matches!(nodes[0], Node::Heading { level: 1, .. }));
437        let seen = visible(&nodes);
438        assert!(seen.contains("Title"), "{seen}");
439        assert!(seen.contains("bold"), "{seen}");
440        assert!(seen.contains("one"), "{seen}");
441        assert!(
442            nodes.iter().any(|n| matches!(n, Node::List { .. })),
443            "expected a list: {nodes:#?}"
444        );
445    }
446
447    #[test]
448    fn fenced_code_survives_verbatim() {
449        let nodes = render("```rust\nlet x = 1;\n```\n");
450        let Some(Node::CodeBlock { language, code }) = nodes.first() else {
451            panic!("expected a code block, got {nodes:#?}");
452        };
453        assert_eq!(language.as_deref(), Some("rust"));
454        assert_eq!(code, "let x = 1;\n");
455    }
456
457    /// The whole point. Embedded HTML must be readable, never runnable.
458    #[test]
459    fn raw_html_becomes_literal_text() {
460        for body in [
461            "<script>alert(1)</script>",
462            "<img src=x onerror=alert(1)>",
463            "<iframe src=\"https://evil.example\"></iframe>",
464            "hello <b>there</b>",
465            "<svg/onload=alert(1)>",
466        ] {
467            let nodes = render(body);
468            let seen = visible(&nodes);
469            assert!(
470                seen.contains('<'),
471                "markup should survive as text for {body:?}, got {seen:?}"
472            );
473            // Nothing anywhere in the tree claims to be a link.
474            assert!(
475                links(&nodes).is_empty(),
476                "raw html produced a link for {body:?}"
477            );
478        }
479    }
480
481    #[test]
482    fn dangerous_link_schemes_lose_the_link_and_keep_the_text() {
483        for body in [
484            "[click](javascript:alert(1))",
485            "[click](JaVaScRiPt:alert(1))",
486            "[click](  javascript:alert(1)  )",
487            "[click](data:text/html;base64,PHNjcmlwdD4=)",
488            "[click](vbscript:msgbox)",
489            "[click](file:///etc/passwd)",
490        ] {
491            let nodes = render(body);
492            assert!(
493                links(&nodes).is_empty(),
494                "{body:?} should not produce a link, got {:?}",
495                links(&nodes)
496            );
497            assert!(
498                visible(&nodes).contains("click"),
499                "{body:?} should keep its text"
500            );
501        }
502    }
503
504    #[test]
505    fn control_characters_cannot_smuggle_a_scheme() {
506        // A NUL or newline inside the scheme is the classic way past a
507        // naive `starts_with("javascript:")` check.
508        for body in [
509            "[x](java\u{0}script:alert(1))",
510            "[x](java\nscript:alert(1))",
511            "[x](\u{0}javascript:alert(1))",
512        ] {
513            let nodes = render(body);
514            assert!(
515                links(&nodes).is_empty(),
516                "{body:?} smuggled a link through: {:?}",
517                links(&nodes)
518            );
519        }
520    }
521
522    #[test]
523    fn ordinary_links_are_kept() {
524        let nodes = render("[docs](https://example.com/a?b=1#c) and [mail](mailto:a@b.c)");
525        let hrefs = links(&nodes);
526        assert!(
527            hrefs.contains(&"https://example.com/a?b=1#c".to_owned()),
528            "{hrefs:?}"
529        );
530        assert!(hrefs.contains(&"mailto:a@b.c".to_owned()), "{hrefs:?}");
531    }
532
533    #[test]
534    fn relative_and_fragment_links_are_kept() {
535        // No scheme means it cannot navigate off a bundled page.
536        let nodes = render("[a](#section) [b](./notes.md) [c](/abs/path)");
537        assert_eq!(links(&nodes).len(), 3, "{:?}", links(&nodes));
538    }
539
540    /// A remote image is a read receipt: fetching it tells the sender
541    /// when the message was opened, and from which IP.
542    #[test]
543    fn remote_images_are_blocked_local_ones_are_not() {
544        let nodes = render("![tracker](https://evil.example/pixel.png)");
545        assert_eq!(
546            images(&nodes),
547            vec![ImageSource::Blocked {
548                url: "https://evil.example/pixel.png".to_owned()
549            }]
550        );
551
552        let nodes = render("![diagram](./diagram.png)");
553        assert_eq!(
554            images(&nodes),
555            vec![ImageSource::Local {
556                path: "./diagram.png".to_owned()
557            }]
558        );
559    }
560
561    #[test]
562    fn image_alt_text_is_plain() {
563        let nodes = render("![<script>x</script>](https://e.example/a.png)");
564        let Some(Node::Paragraph { content }) = nodes.first() else {
565            panic!("expected paragraph, got {nodes:#?}");
566        };
567        let Some(Inline::Image { alt, .. }) = content.first() else {
568            panic!("expected image, got {content:#?}");
569        };
570        assert!(alt.contains("script"), "alt should keep text: {alt:?}");
571    }
572
573    #[test]
574    fn a_plain_message_is_still_a_paragraph() {
575        // The overwhelmingly common case: one line of prose.
576        let nodes = render("just a normal message");
577        assert_eq!(visible(&nodes).trim(), "just a normal message");
578    }
579
580    #[test]
581    fn empty_input_is_empty_output() {
582        assert!(render("").is_empty());
583        assert!(render("   \n  \n").is_empty());
584    }
585
586    #[test]
587    fn nested_structures_do_not_lose_content() {
588        let nodes = render("> quoted **bold**\n>\n> - item one\n> - item two\n");
589        let seen = visible(&nodes);
590        assert!(seen.contains("quoted"), "{seen}");
591        assert!(seen.contains("bold"), "{seen}");
592        assert!(seen.contains("item one"), "{seen}");
593        assert!(seen.contains("item two"), "{seen}");
594    }
595
596    #[test]
597    fn scheme_detection_does_not_trip_on_paths() {
598        // A colon after a slash belongs to the path, not a scheme.
599        assert_eq!(scheme_of("https://a.example/x:y"), Some("https".into()));
600        assert_eq!(scheme_of("/path/to:thing"), None);
601        assert_eq!(scheme_of("#frag:ment"), None);
602        assert_eq!(scheme_of("no-colon-here"), None);
603        assert_eq!(scheme_of("1nvalid:x"), None);
604    }
605}