🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! 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}"
            );
        }
    }
}