From 466083f662f1383a5bf26426196536be3a9b2ee3 Mon Sep 17 00:00:00 2001 From: Budi Syahiddin Date: Sat, 3 Jan 2026 14:54:37 +0800 Subject: [PATCH 1/4] feat: allow iframes while preventing XSS --- .github/workflows/build-release.yml | 2 +- .github/workflows/github-pages.yml | 8 +- markdown-renderer/Cargo.lock | 241 ++++++++++++++- markdown-renderer/Cargo.toml | 2 + markdown-renderer/src/lib.rs | 438 +++++++++++++++++++++++++++- src/components/MarkdownRenderer.tsx | 84 +++++- vite.config.ts | 2 +- 7 files changed, 760 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 7eea905..b1706e6 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -36,7 +36,7 @@ jobs: with: bun-version: latest - name: Build Rust project - run: cd markdown-renderer && wasm-pack build --target web --release + run: cd markdown-renderer && wasm-pack build --target web --features sanitize - name: Post-process wasm package run: bun process_wasm_pkg.js - name: Install dependencies (workspace) diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 67666f6..bfa9095 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -3,7 +3,7 @@ name: Deploy Example to GitHub Pages on: push: branches: - - main + - main jobs: deploy: @@ -28,7 +28,7 @@ jobs: run: cargo install wasm-pack - name: Build Rust project - run: cd markdown-renderer && wasm-pack build --target web --release + run: cd markdown-renderer && wasm-pack build --target web --features sanitize - name: Post-process wasm package run: bun process_wasm_pkg.js @@ -43,11 +43,11 @@ jobs: run: cd example && bun install - name: Build example - run: cd example && bun run build + run: cd example && bun run build - name: Deploy to GitHub Pages uses: jamesives/github-pages-deploy-action@v4 with: token: ${{ secrets.GITHUB_TOKEN }} branch: gh-pages - folder: example/dist + folder: example/dist diff --git a/markdown-renderer/Cargo.lock b/markdown-renderer/Cargo.lock index ec4867f..41fab90 100644 --- a/markdown-renderer/Cargo.lock +++ b/markdown-renderer/Cargo.lock @@ -17,6 +17,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + [[package]] name = "anes" version = "0.1.6" @@ -470,6 +483,29 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "csv" version = "1.4.0" @@ -530,6 +566,21 @@ dependencies = [ "syn", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "ecow" version = "0.2.6" @@ -707,6 +758,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -804,7 +865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10e7e97ce840a6a70e7901e240ec65ba61106b66b37a4a1b899a2ce484248463" dependencies = [ "log", - "phf", + "phf 0.13.1", ] [[package]] @@ -819,7 +880,7 @@ dependencies = [ "kurbo 0.12.0", "log", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash", "siphasher", "skrifa", @@ -867,6 +928,17 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + [[package]] name = "hypher" version = "0.1.6" @@ -1305,10 +1377,23 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "markdown-renderer" version = "0.1.0" dependencies = [ + "ammonia", "comrak", "criterion", "mini-moka", @@ -1317,6 +1402,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1364,6 +1471,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "num-bigint" version = "0.4.6" @@ -1478,17 +1591,47 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -1496,7 +1639,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1505,13 +1661,22 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -1636,6 +1801,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -2087,6 +2258,31 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + [[package]] name = "strum" version = "0.27.2" @@ -2180,6 +2376,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thin-vec" version = "0.2.14" @@ -2501,7 +2708,7 @@ dependencies = [ "lipsum", "memchr", "palette", - "phf", + "phf 0.13.1", "png 0.17.16", "qcms", "rayon", @@ -2806,6 +3013,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2938,6 +3151,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "weezl" version = "0.1.10" diff --git a/markdown-renderer/Cargo.toml b/markdown-renderer/Cargo.toml index 84ccfad..6b566d1 100644 --- a/markdown-renderer/Cargo.toml +++ b/markdown-renderer/Cargo.toml @@ -11,10 +11,12 @@ wasm-bindgen = "0.2.105" comrak = { git = "https://github.com/DoublePrecision/comrak.git", branch = "feat/math-renderer", default-features = false } syntect = { version = "5.0.0", default-features = false, features = ["default-fancy"]} mini-moka = { version = "0.10.3", optional = true } +ammonia = { version = "4.1.2", optional = true } [features] default = ["mini-moka"] cached-code = ["mini-moka"] +sanitize = ["ammonia"] [profile.release] opt-level = 's' diff --git a/markdown-renderer/src/lib.rs b/markdown-renderer/src/lib.rs index 3934dc0..462554c 100644 --- a/markdown-renderer/src/lib.rs +++ b/markdown-renderer/src/lib.rs @@ -3,6 +3,9 @@ use std::{collections::HashMap, sync::LazyLock}; use syntect::{dumps::from_binary, highlighting::ThemeSet, parsing::SyntaxSet}; use wasm_bindgen::prelude::*; +#[cfg(feature = "sanitize")] +use std::collections::HashSet; + use crate::syntect_plugin::{SyntectAdapterCached, SyntectAdapterCachedBuilder}; mod syntect_plugin; @@ -106,6 +109,25 @@ pub static SYNTAX_SET: LazyLock = pub static THEME_SET: LazyLock = LazyLock::new(|| from_binary(include_bytes!("../sublime/themes/all.themedump"))); +#[cfg(feature = "sanitize")] +static OPTIONS: LazyLock = LazyLock::new(|| { + let mut options = Options::default(); + options.extension.table = true; + options.extension.tasklist = true; + options.extension.alerts = true; + options.extension.underline = true; + options.extension.strikethrough = true; + options.extension.spoiler = true; + options.extension.superscript = true; + options.extension.math_code = true; + options.extension.math_dollars = true; + options.extension.front_matter_delimiter = Some("---".into()); + options.render.r#unsafe = true; + + options +}); + +#[cfg(not(feature = "sanitize"))] static OPTIONS: LazyLock = LazyLock::new(|| { let mut options = Options::default(); options.extension.table = true; @@ -118,6 +140,7 @@ static OPTIONS: LazyLock = LazyLock::new(|| { options.extension.math_code = true; options.extension.math_dollars = true; options.extension.front_matter_delimiter = Some("---".into()); + options.render.r#unsafe = true; options }); @@ -149,9 +172,318 @@ static PLUGINS: LazyLock>> = LazyLock::ne map }); +#[cfg(feature = "sanitize")] +static SANITIZER: LazyLock> = LazyLock::new(|| { + let mut builder = ammonia::Builder::default(); + + // Get default tags and add iframe + SVG elements + let mut tags = HashSet::new(); + // Standard HTML tags from ammonia defaults + for tag in [ + "a", + "abbr", + "acronym", + "area", + "article", + "aside", + "b", + "bdi", + "bdo", + "blockquote", + "br", + "caption", + "center", + "cite", + "code", + "col", + "colgroup", + "data", + "dd", + "del", + "details", + "dfn", + "div", + "dl", + "dt", + "em", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hgroup", + "hr", + "i", + "img", + "ins", + "kbd", + "li", + "map", + "mark", + "menu", + "nav", + "ol", + "p", + "pre", + "q", + "rp", + "rt", + "rtc", + "ruby", + "s", + "samp", + "small", + "span", + "strike", + "strong", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "time", + "tr", + "tt", + "u", + "ul", + "var", + "wbr", + // Additional tags for our use case + "iframe", + "input", + // SVG elements + "svg", + "g", + "path", + "use", + "defs", + "symbol", + "circle", + "ellipse", + "line", + "polygon", + "polyline", + "rect", + "text", + "tspan", + "clipPath", + "mask", + "linearGradient", + "radialGradient", + "stop", + "filter", + "feGaussianBlur", + "feOffset", + "feBlend", + "feMerge", + "feMergeNode", + "image", + "foreignObject", + "title", + "desc", + ] { + tags.insert(tag); + } + builder.tags(tags); + + // Allow necessary attributes for various elements + let mut tag_attributes: HashMap<&str, HashSet<&str>> = HashMap::new(); + + // iframe attributes + let iframe_attrs: HashSet<&str> = [ + "src", + "width", + "height", + "frameborder", + "allow", + "allowfullscreen", + "loading", + "title", + "sandbox", + "referrerpolicy", + ] + .into_iter() + .collect(); + tag_attributes.insert("iframe", iframe_attrs); + + // SVG attributes - common attributes used across many SVG elements + let svg_common_attrs: HashSet<&str> = [ + "id", + "class", + "style", + "transform", + "fill", + "fill-rule", + "fill-opacity", + "stroke", + "stroke-width", + "stroke-linecap", + "stroke-linejoin", + "stroke-opacity", + "stroke-dasharray", + "stroke-dashoffset", + "opacity", + "clip-path", + "clip-rule", + "mask", + "filter", + "x", + "y", + "width", + "height", + "rx", + "ry", + "cx", + "cy", + "r", + "d", + "points", + "x1", + "y1", + "x2", + "y2", + "href", + "xlink:href", + "overflow", + "viewBox", + "preserveAspectRatio", + "xmlns", + "xmlns:xlink", + "xmlns:h5", + "version", + "dx", + "dy", + "text-anchor", + "dominant-baseline", + "font-family", + "font-size", + "font-weight", + "font-style", + "offset", + "stop-color", + "stop-opacity", + "gradientUnits", + "gradientTransform", + "spreadMethod", + "fx", + "fy", + "stdDeviation", + "result", + "in", + "in2", + "mode", + "requiredFeatures", + "systemLanguage", + ] + .into_iter() + .collect(); + + // Apply common SVG attributes to all SVG elements + for svg_tag in [ + "svg", + "g", + "path", + "use", + "defs", + "symbol", + "circle", + "ellipse", + "line", + "polygon", + "polyline", + "rect", + "text", + "tspan", + "clipPath", + "mask", + "linearGradient", + "radialGradient", + "stop", + "filter", + "feGaussianBlur", + "feOffset", + "feBlend", + "feMerge", + "feMergeNode", + "image", + "foreignObject", + ] { + tag_attributes.insert(svg_tag, svg_common_attrs.clone()); + } + + // Input attributes (for tasklists) + let input_attrs: HashSet<&str> = ["type", "checked", "disabled"].into_iter().collect(); + tag_attributes.insert("input", input_attrs); + + // Image attributes + let img_attrs: HashSet<&str> = ["src", "alt", "title", "width", "height"] + .into_iter() + .collect(); + tag_attributes.insert("img", img_attrs); + + // Link attributes (note: 'rel' is managed by ammonia automatically) + let a_attrs: HashSet<&str> = ["href", "title"].into_iter().collect(); + tag_attributes.insert("a", a_attrs); + + // Table alignment + let td_attrs: HashSet<&str> = ["align"].into_iter().collect(); + tag_attributes.insert("td", td_attrs.clone()); + tag_attributes.insert("th", td_attrs); + + builder.tag_attributes(tag_attributes); + + // Allow style and class on all elements + builder.add_generic_attributes(["style", "class"]); + + // Allow certain URL schemes + let url_schemes: HashSet<&str> = ["https", "http", "mailto", "data"].into_iter().collect(); + builder.url_schemes(url_schemes); + + // Use attribute filter to preserve SVG xlink:href fragment references (like #glyph123) + // which ammonia would otherwise strip as they don't match URL schemes + builder.attribute_filter(|_element, attribute, value| { + // Preserve xlink:href attributes with fragment references in SVG elements + if attribute == "xlink:href" && value.starts_with('#') { + Some(value.into()) + } else if attribute == "href" && value.starts_with('#') { + // Also preserve regular href with fragments (for SVG 2.0 compatibility) + Some(value.into()) + } else { + // Pass through all other attributes unchanged + Some(value.into()) + } + }); + + builder +}); + +#[cfg(feature = "sanitize")] +fn sanitize_html(html: &str) -> String { + SANITIZER.clean(html).to_string() +} + #[wasm_bindgen] pub fn render_md(markdown: &str, theme: Themes) -> String { - markdown_to_html_with_plugins(markdown, &OPTIONS, &PLUGINS[theme.to_str()]) + let html = markdown_to_html_with_plugins(markdown, &OPTIONS, &PLUGINS[theme.to_str()]); + + #[cfg(feature = "sanitize")] + { + sanitize_html(&html) + } + + #[cfg(not(feature = "sanitize"))] + { + html + } } #[cfg(test)] @@ -298,7 +630,111 @@ mod test { .to_string() } + /// Test that XSS attacks are blocked when sanitize feature is enabled + #[test] + #[cfg(feature = "sanitize")] + fn test_xss_protection() { + // Test onerror XSS + let xss_input = r#""#; + let result = render_md(xss_input, Themes::OneHalfDark); + assert!( + !result.contains("onerror"), + "onerror attribute should be stripped: {}", + result + ); + assert!( + !result.contains("alert"), + "alert should be stripped: {}", + result + ); + + // Test onclick XSS + let xss_input2 = r#"
Click me
"#; + let result2 = render_md(xss_input2, Themes::OneHalfDark); + assert!( + !result2.contains("onclick"), + "onclick attribute should be stripped: {}", + result2 + ); + + // Test javascript: URL + let xss_input3 = r#"Click"#; + let result3 = render_md(xss_input3, Themes::OneHalfDark); + assert!( + !result3.contains("javascript:"), + "javascript: URL should be stripped: {}", + result3 + ); + + // Test script tag + let xss_input4 = r#""#; + let result4 = render_md(xss_input4, Themes::OneHalfDark); + assert!( + !result4.contains("