michal/tit
Browse tree · Show commit · Download archive
Blob: src/markdown.rs
use std::collections::{HashMap, HashSet};
use std::fmt;
use ammonia::{Builder, UrlRelative};
use pulldown_cmark::{Event, Parser, Tag, TagEnd, html};
use url::Url;
pub(crate) const MAX_MARKDOWN_BYTES: usize = 256 * 1024;
const MAX_RENDERED_BYTES: usize = 1024 * 1024;
const LIMIT_MESSAGE: &str = "<p>Markdown content exceeds the rendering limit.</p>\n";
#[derive(Default)]
pub struct RenderedMarkdown(String);
impl fmt::Display for RenderedMarkdown {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
pub fn render(source: &str) -> RenderedMarkdown {
render_with_base(source, None)
}
pub fn render_repository(source: &str, base: &str) -> RenderedMarkdown {
render_with_base(source, Some(base))
}
fn render_with_base(source: &str, base: Option<&str>) -> RenderedMarkdown {
if source.len() > MAX_MARKDOWN_BYTES {
return RenderedMarkdown(LIMIT_MESSAGE.to_owned());
}
let mut skipped_link = false;
let events = Parser::new(source).filter_map(|event| match event {
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => {
if !safe_link(&dest_url) {
skipped_link = true;
return None;
}
let destination = match base {
Some(base) => resolve_repository_link(&dest_url, base),
None => Some(dest_url.to_string()),
};
match destination {
Some(destination) => Some(Event::Start(Tag::Link {
link_type,
dest_url: destination.into(),
title,
id,
})),
None => {
skipped_link = true;
None
}
}
}
Event::End(TagEnd::Link) if skipped_link => {
skipped_link = false;
None
}
Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) => None,
Event::Html(_) | Event::InlineHtml(_) => None,
event => Some(event),
});
let mut rendered = String::new();
html::push_html(&mut rendered, events);
let tags = HashSet::from([
"a",
"blockquote",
"br",
"code",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"li",
"ol",
"p",
"pre",
"strong",
"ul",
]);
let tag_attributes = HashMap::from([
("a", HashSet::from(["href", "title"])),
("ol", HashSet::from(["start"])),
]);
let url_schemes = HashSet::from(["http", "https", "mailto"]);
let mut sanitizer = Builder::new();
sanitizer
.tags(tags)
.tag_attributes(tag_attributes)
.generic_attributes(HashSet::new())
.url_schemes(url_schemes)
.url_relative(UrlRelative::PassThrough)
.link_rel(Some("nofollow noopener noreferrer"));
let rendered = sanitizer.clean(&rendered).to_string();
if rendered.len() > MAX_RENDERED_BYTES {
RenderedMarkdown(LIMIT_MESSAGE.to_owned())
} else {
RenderedMarkdown(rendered)
}
}
fn safe_link(destination: &str) -> bool {
if destination.trim() != destination
|| destination.chars().any(char::is_control)
|| destination.contains('\\')
|| destination.starts_with("//")
{
return false;
}
match Url::parse(destination) {
Ok(url) => matches!(url.scheme(), "http" | "https" | "mailto"),
Err(url::ParseError::RelativeUrlWithoutBase) => true,
Err(_) => false,
}
}
fn resolve_repository_link(destination: &str, base: &str) -> Option<String> {
if Url::parse(destination).is_ok()
|| destination.starts_with('/')
|| destination.starts_with('#')
|| destination.starts_with('?')
{
return Some(destination.to_owned());
}
let origin = Url::parse("https://tit.invalid/").expect("the Markdown origin is valid");
let base = origin.join(base).ok()?;
let resolved = base.join(destination).ok()?;
if !resolved.path().starts_with(base.path()) {
return None;
}
let mut link = resolved.path().to_owned();
if let Some(query) = resolved.query() {
link.push('?');
link.push_str(query);
}
if let Some(fragment) = resolved.fragment() {
link.push('#');
link.push_str(fragment);
}
Some(link)
}
#[cfg(test)]
mod tests {
use super::{render, render_repository};
#[test]
fn renders_the_documented_subset() {
let output = render(
"# Title\n\nA **strong** and *short* paragraph.\n\n- one\n- two\n\n> quote\n\n```rust\nlet safe = true;\n```\n\n[local](docs/guide.md) [web](https://example.com).",
)
.to_string();
assert!(output.contains("<h1>Title</h1>"));
assert!(output.contains("<strong>strong</strong>"));
assert!(output.contains("<em>short</em>"));
assert!(output.contains("<ul>"));
assert!(output.contains("<blockquote>"));
assert!(output.contains("<pre><code>let safe = true;"));
assert!(output.contains("href=\"docs/guide.md\""));
assert!(output.contains("href=\"https://example.com\""));
assert!(!output.contains("class="));
}
#[test]
fn removes_html_images_and_active_links() {
let output = render(
"<script>alert(1)</script>\n\n<img src=x onerror=alert(2)>\n\n\n\n[bad](javascript:alert(3)) [data](data:text/html,x) [network](//example.com/x) [mail](mailto:user@example.com)",
)
.to_string();
assert!(!output.contains("script"));
assert!(!output.contains("img"));
assert!(!output.contains("tracker.example"));
assert!(!output.contains("javascript"));
assert!(!output.contains("data:text"));
assert!(!output.contains("//example.com"));
assert!(output.contains("alt"));
assert!(output.contains("href=\"mailto:user@example.com\""));
assert!(output.contains("rel=\"nofollow noopener noreferrer\""));
}
#[test]
fn escapes_text_and_rejects_obscure_schemes() {
let output = render(
"Text & <b>markup</b>. [case](JaVaScRiPt:alert(1)) [space]( javascript:alert(2)) [slash](\\\\example.com/x)",
)
.to_string();
assert!(output.contains("Text & markup."));
assert!(!output.contains("href="));
}
#[test]
fn resolves_repository_links_inside_the_current_revision() {
let output = render_repository(
"[license](LICENSE) [guide](docs/guide.md#start) [anchor](#usage) \
[site](/help) [outside](../secret)",
"/alice/example/blob/abc123/",
)
.to_string();
assert!(output.contains("href=\"/alice/example/blob/abc123/LICENSE\""));
assert!(output.contains("href=\"/alice/example/blob/abc123/docs/guide.md#start\""));
assert!(output.contains("href=\"#usage\""));
assert!(output.contains("href=\"/help\""));
assert!(!output.contains("href=\"../secret\""));
}
#[test]
fn bounds_source_and_rendered_content() {
assert_eq!(
render(&"x".repeat(super::MAX_MARKDOWN_BYTES + 1)).to_string(),
super::LIMIT_MESSAGE
);
assert_eq!(
render(&"<".repeat(super::MAX_MARKDOWN_BYTES)).to_string(),
super::LIMIT_MESSAGE
);
}
}