a73x

5c0d11b1

feat(server): tighten readme sanitizer img URL schemes

alex emery   2026-04-12 18:36


diff --git a/src/server/http/repo/readme.rs b/src/server/http/repo/readme.rs
index ec16449..729345e 100644
--- a/src/server/http/repo/readme.rs
+++ b/src/server/http/repo/readme.rs
@@ -117,8 +117,20 @@ fn html_escape(s: &str) -> String {
}

fn sanitize(html: &str) -> String {
    // Task 5 will tighten the policy (img URL schemes). Default is fine for now.
    ammonia::clean(html)
    use std::collections::HashSet;

    // Restrict <img src> URL schemes to http/https only — no data:, no javascript:.
    // ammonia::Builder::url_schemes() applies the allowlist to ALL URL-bearing
    // attributes, which is what we want.
    let mut schemes: HashSet<&str> = HashSet::new();
    schemes.insert("http");
    schemes.insert("https");
    schemes.insert("mailto");

    ammonia::Builder::default()
        .url_schemes(schemes)
        .clean(html)
        .to_string()
}

#[cfg(test)]
@@ -263,4 +275,50 @@ mod tests {

        assert!(load_readme(&repo).is_none());
    }

    #[test]
    fn markdown_strips_script_tag() {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"# t\n\n<script>alert(1)</script>\n"),
        ]);
        let html = load_readme(&repo).unwrap().html;
        assert!(!html.contains("<script>"), "got: {}", html);
    }

    #[test]
    fn markdown_strips_javascript_href() {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"[click](javascript:alert(1))\n"),
        ]);
        let html = load_readme(&repo).unwrap().html;
        assert!(!html.contains("javascript:"), "got: {}", html);
    }

    #[test]
    fn markdown_strips_onerror_attribute() {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"<img src=\"https://x/y.png\" onerror=\"alert(1)\">\n"),
        ]);
        let html = load_readme(&repo).unwrap().html;
        assert!(!html.contains("onerror"), "got: {}", html);
    }

    #[test]
    fn markdown_strips_iframe() {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"<iframe src=\"https://evil.example/\"></iframe>\n"),
        ]);
        let html = load_readme(&repo).unwrap().html;
        assert!(!html.contains("<iframe"), "got: {}", html);
    }

    #[test]
    fn markdown_strips_data_image_uri() {
        let (repo, _tmp) = repo_with_files(&[
            ("README.md", b"<img src=\"data:image/png;base64,AAAA\">\n"),
        ]);
        let html = load_readme(&repo).unwrap().html;
        // Either the whole <img> is dropped or the src attr is gone.
        assert!(!html.contains("data:"), "got: {}", html);
    }
}