//! Rendering a repository's readme, safely.
//!
//! A readme is markdown, and markdown may contain raw HTML. The content is a
//! repository's, not the server's, so the HTML is sanitized before it is
//! embedded in a page: anything that could run - scripts, event handlers, a
//! `javascript:` link - is removed, and what is left is a subset a browser can
//! only lay out.
//!
//! [`render`] is the only way a page embeds content it did not write itself,
//! which is why it lives on its own rather than inside a view.
use pulldown_cmark::{Event, Options, Parser, Tag, html};
use std::path::Path;
/// The markdown extensions the forge shows, on top of CommonMark.
///
/// Tables and footnotes are ordinary in a readme; strikethrough and task lists
/// are common enough that a readme using them would otherwise show the syntax.
/// Raw HTML is deliberately not gated behind an option: it is part of
/// CommonMark, and the sanitizer is what makes it safe.
fn options() -> Options {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options
}
/// The sanitizer permits only checkbox inputs — no text fields, no hidden inputs,
/// no way to submit a form. The `type` attribute is gated by value rather than
/// by name, so a non-checkbox input cannot be injected via raw HTML.
fn sanitizer() -> ammonia::Builder<'static> {
let mut builder = ammonia::Builder::default();
builder
.add_tags(["input"])
.add_tag_attributes("input", ["checked", "disabled"])
.add_tag_attribute_values("input", "type", ["checkbox"]);
builder
}
/// Sanitize HTML content using the same rules as markdown rendering.
///
/// This is used for HTML received from remote ActivityPub instances, which may
/// contain untrusted HTML. Returns sanitized HTML safe for embedding.
pub fn sanitize_html(html: &str) -> String {
sanitizer().clean(html).to_string()
}
/// Renders `source` from markdown to HTML that is safe to embed.
pub fn render(source: &str) -> String {
let mut rendered = String::with_capacity(source.len());
html::push_html(&mut rendered, Parser::new_ext(source, options()));
// The sanitizer is the last step rather than the first: it has to see the
// HTML the renderer produced, including any raw HTML it passed through.
sanitizer().clean(&rendered).to_string()
}
/// Renders `source` from markdown to HTML, rewriting relative file links to tree URLs.
///
/// This is used when rendering markdown files in the repository browser, so that
/// links like `[docs](./docs/guide.md)` become navigable links to the tree view.
pub fn render_with_repo_context(
source: &str,
owner: &str,
repo_slug: &str,
reference: &str,
file_path: &str,
) -> String {
let file_dir = Path::new(file_path).parent().unwrap_or(Path::new(""));
let events = Parser::new_ext(source, options()).map(|event| match event {
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => {
let resolved = resolve_link(&dest_url, file_dir, owner, repo_slug, reference);
Event::Start(Tag::Link {
link_type,
dest_url: resolved.into(),
title,
id,
})
}
other => other,
});
let mut rendered = String::with_capacity(source.len());
html::push_html(&mut rendered, events);
sanitizer().clean(&rendered).to_string()
}
fn resolve_link(
dest: &str,
file_dir: &Path,
owner: &str,
repo_slug: &str,
reference: &str,
) -> String {
// Leave absolute URLs unchanged
if dest.starts_with("http://") || dest.starts_with("https://") || dest.starts_with("mailto:") {
return dest.to_string();
}
// Leave anchor links unchanged
if dest.starts_with('#') {
return dest.to_string();
}
// Handle absolute paths (starting with /)
if dest.starts_with('/') {
let path = dest.trim_start_matches('/');
return format!("/~{}/{}/tree/{}/{}", owner, repo_slug, reference, path);
}
// Resolve relative paths against the file's directory
let full_path = file_dir.join(dest);
let normalized = normalize_path(&full_path);
format!(
"/~{}/{}/tree/{}/{}",
owner, repo_slug, reference, normalized
)
}
fn normalize_path(path: &Path) -> String {
let mut components = Vec::new();
for component in path.components() {
match component {
std::path::Component::Normal(c) => {
components.push(c.to_string_lossy().into_owned());
}
std::path::Component::ParentDir => {
components.pop();
}
std::path::Component::CurDir => {
// Skip .
}
_ => {}
}
}
components.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_markdown_is_rendered() {
let rendered = render("# Notes\n\n- one\n- two\n\n*emphasis*\n");
assert!(rendered.contains("<h1>Notes</h1>"), "{rendered}");
assert!(rendered.contains("<li>one</li>"), "{rendered}");
assert!(rendered.contains("<em>emphasis</em>"), "{rendered}");
}
#[test]
fn raw_html_is_removed() {
// A readme is untrusted input. Whatever a browser could execute from it
// cannot survive rendering.
for (source, forbidden) in [
("<script>alert(1)</script>", "<script"),
("<img src=x onerror=alert(1)>", "onerror"),
("<a href=\"javascript:alert(1)\">click</a>", "javascript:"),
("<style>body{display:none}</style>", "<style"),
("<iframe src=\"https://example.com\"></iframe>", "<iframe"),
] {
let rendered = render(source);
assert!(
!rendered.contains(forbidden),
"{source:?} should not survive as {forbidden:?}, got:\n{rendered}"
);
}
}
#[test]
fn a_javascript_link_written_as_markdown_is_removed() {
let rendered = render("[click](javascript:alert(1))");
assert!(
!rendered.contains("javascript:"),
"a markdown link should not carry a script, got:\n{rendered}"
);
}
#[test]
fn an_ordinary_link_survives() {
let rendered = render("[home](https://example.com/)");
assert!(
rendered.contains("href=\"https://example.com/\""),
"{rendered}"
);
}
#[test]
fn a_relative_link_is_rewritten_to_tree_url() {
let rendered = render_with_repo_context(
"[guide](./docs/guide.md)",
"alice",
"myrepo",
"main",
"README.md",
);
assert!(
rendered.contains("href=\"/~alice/myrepo/tree/main/docs/guide.md\""),
"{rendered}"
);
}
#[test]
fn a_relative_link_from_subdirectory_is_resolved() {
let rendered = render_with_repo_context(
"[sibling](./sibling.md)",
"alice",
"myrepo",
"main",
"docs/guide.md",
);
assert!(
rendered.contains("href=\"/~alice/myrepo/tree/main/docs/sibling.md\""),
"{rendered}"
);
}
#[test]
fn an_absolute_path_link_is_rewritten_to_tree_url() {
let rendered = render_with_repo_context(
"[root](/docs/guide.md)",
"alice",
"myrepo",
"main",
"README.md",
);
assert!(
rendered.contains("href=\"/~alice/myrepo/tree/main/docs/guide.md\""),
"{rendered}"
);
}
#[test]
fn external_links_are_not_rewritten() {
let rendered = render_with_repo_context(
"[external](https://example.com/path)",
"alice",
"myrepo",
"main",
"README.md",
);
assert!(
rendered.contains("href=\"https://example.com/path\""),
"{rendered}"
);
}
#[test]
fn unchecked_task_list_is_rendered() {
let rendered = render("- [ ] todo\n- [ ] another\n");
assert!(
rendered.contains("<input"),
"checkbox input should survive:\n{rendered}"
);
assert!(
rendered.contains("type=\"checkbox\""),
"type should be checkbox:\n{rendered}"
);
assert!(
rendered.contains("disabled"),
"checkbox should be disabled:\n{rendered}"
);
}
#[test]
fn checked_task_list_is_rendered() {
let rendered = render("- [x] done\n- [X] also done\n");
assert!(
rendered.contains("<input"),
"checkbox input should survive:\n{rendered}"
);
assert!(
rendered.contains("type=\"checkbox\""),
"type should be checkbox:\n{rendered}"
);
assert!(
rendered.contains("checked"),
"checkbox should be checked:\n{rendered}"
);
}
#[test]
fn raw_input_is_stripped() {
// Only checkbox inputs from task lists should survive; raw HTML inputs are not allowed.
for (source, forbidden) in [
("<input type=\"text\">", "type=\"text\""),
("<input type=\"hidden\" name=\"x\">", "type=\"hidden\""),
("<input formaction=\"https://example.com\">", "formaction"),
] {
let rendered = render(source);
assert!(
!rendered.contains(forbidden),
"{source:?} should not survive as {forbidden:?}, got:\n{rendered}"
);
}
}
}