RustForge

rustforge

RustForge: the read-only, cgit-style git web host serving this site

rustforge / src / markdown.rs
3823 bytes ยท raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
//! README rendering: Markdown -> HTML, safely.
//!
//! pulldown-cmark normally copies raw HTML from the Markdown straight into the
//! output. A README is untrusted input (anyone can commit one), so we turn raw
//! HTML into plain text and drop `javascript:`-style links.

use pulldown_cmark::{html, CowStr, Event, Options, Parser, Tag};

/// Where relative links and images in the README should point.
pub struct LinkBase<'a> {
    /// Prefix for relative links, e.g. "/rust-kernel/tree/"
    pub links: &'a str,
    /// Prefix for relative images, e.g. "/rust-kernel/raw/"
    pub images: &'a str,
}

pub fn render(markdown: &str, base: Option<&LinkBase>) -> String {
    let opts = Options::ENABLE_TABLES
        | Options::ENABLE_STRIKETHROUGH
        | Options::ENABLE_TASKLISTS
        | Options::ENABLE_FOOTNOTES;
    let events = Parser::new_ext(markdown, opts).map(|event| match event {
        // Raw HTML is shown as text instead of being executed by the browser.
        Event::Html(s) | Event::InlineHtml(s) => Event::Text(s),
        Event::Start(Tag::Link { link_type, dest_url, title, id }) => Event::Start(Tag::Link {
            link_type,
            dest_url: fix_url(dest_url, base.map(|b| b.links)),
            title,
            id,
        }),
        Event::Start(Tag::Image { link_type, dest_url, title, id }) => Event::Start(Tag::Image {
            link_type,
            dest_url: fix_url(dest_url, base.map(|b| b.images)),
            title,
            id,
        }),
        other => other,
    });
    let mut out = String::new();
    html::push_html(&mut out, events);
    out
}

/// Keep safe absolute URLs, rewrite relative ones, blank out everything else.
fn fix_url<'a>(url: CowStr<'a>, base: Option<&str>) -> CowStr<'a> {
    let lower = url.trim().to_ascii_lowercase();
    if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:") {
        return url;
    }
    if lower.starts_with('#') || lower.starts_with('/') {
        return url;
    }
    if lower.contains(':') {
        // javascript:, data:, vbscript:, file:, ... -> not allowed
        let before_slash = lower.split('/').next().unwrap_or("");
        if before_slash.contains(':') {
            return CowStr::Borrowed("#");
        }
    }
    match base {
        Some(prefix) => {
            let rel = url.trim_start_matches("./");
            CowStr::from(format!("{prefix}{rel}"))
        }
        None => url,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_basic_markdown() {
        let html = render("# Title\n\nSome *text*.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n", None);
        assert!(html.contains("<h1>Title</h1>"));
        assert!(html.contains("<em>text</em>"));
        assert!(html.contains("<table>"));
    }

    #[test]
    fn raw_html_is_escaped() {
        let html = render("hi <script>alert(1)</script>\n\n<div onclick=x>block</div>\n", None);
        assert!(!html.contains("<script>"));
        assert!(html.contains("&lt;script&gt;"));
        assert!(!html.contains("<div"));
    }

    #[test]
    fn dangerous_links_are_removed() {
        let html = render("[x](javascript:alert(1)) [y](JavaScript:alert(1))", None);
        assert!(!html.to_lowercase().contains("javascript:"));
        let html = render("![i](data:image/svg+xml;base64,AAAA)", None);
        assert!(!html.contains("data:"));
    }

    #[test]
    fn relative_links_are_rewritten() {
        let base = LinkBase { links: "/r/tree/", images: "/r/raw/" };
        let html = render("[doc](docs/a.md) ![img](./boot.png) [web](https://e.com)", Some(&base));
        assert!(html.contains("href=\"/r/tree/docs/a.md\""));
        assert!(html.contains("src=\"/r/raw/boot.png\""));
        assert!(html.contains("href=\"https://e.com\""));
    }
}