.forge/prs/2b56eb82-e562-4fb3-818a-41694f31ef92/patch.diff 10.6 KiB 276 lines Raw
1 From 7720c75386ec650be054717f79872be8d5819477 Mon Sep 17 00:00:00 20012 From: hu13-test <[email protected]>3 Date: Tue, 27 Oct 2026 16:03:05 +00004 Subject: [PATCH] GFM markdown + URL scheme allowlist (#71 #62)5 6 #71: markdown_to_html enables tables, task lists, strikethrough and7 footnotes, and wraps output in .markdown so the new table/task CSS8 applies (wrapping cells instead of scrolling).9 10 #62: link and image destinations are scheme-filtered. HTML entities11 are decoded first (javascript&colon; and &#106;avascript: were live12 XSS), then C0/whitespace stripped; only http/https/mailto/tel and13 scheme-less URLs survive. Everything else (javascript:, data:,14 vbscript:, file:) becomes '#'. Unit tests cover mixed case,15 tab-splitting, entity obfuscation, image syntax, and that safe16 https/relative/mailto/tel/#anchor links are unchanged.17 ---18  src/build.rs        | 188 ++++++++++++++++++++++++++++++++++++++++++--19  static/style.css    |   4 +-20  tests/responsive.rs |   8 +-21  3 files changed, 190 insertions(+), 10 deletions(-)22 23 diff --git a/src/build.rs b/src/build.rs24 index a49f34e..0fcb898 10064425 --- a/src/build.rs26 +++ b/src/build.rs27 @@ -105,22 +105,148 @@ pub struct BuildSummary {28      pub file_count: usize,29  }30  31 +/// Decode the HTML entities a browser would decode before treating a32 +/// URL as a scheme (`javascript&colon;…`, `&#106;avascript:…`).33 +///34 +/// `pulldown-cmark` writes `dest_url` into `href`/`src` and then escapes35 +/// it; a destination that still contains `&colon;` will be decoded by the36 +/// *browser* into `:` — so the scheme check has to decode it first.37 +fn decode_entities(s: &str) -> String {38 +    let mut out = String::with_capacity(s.len());39 +    let mut rest = s;40 +    while let Some(i) = rest.find('&') {41 +        out.push_str(&rest[..i]);42 +        let tail = &rest[i + 1..];43 +        let Some(semi) = tail.find(';') else {44 +            out.push('&');45 +            rest = tail;46 +            continue;47 +        };48 +        let name = &tail[..semi];49 +        let decoded: Option<char> = match name {50 +            "colon" | "Colon" => Some(':'),51 +            "sol" | "Tab" | "NewLine" => Some(match name {52 +                "Tab" => '\t',53 +                "NewLine" => '\n',54 +                _ => '/',55 +            }),56 +            "amp" => Some('&'),57 +            "lt" => Some('<'),58 +            "gt" => Some('>'),59 +            "quot" => Some('"'),60 +            "apos" => Some('\''),61 +            "nbsp" => Some('\u{a0}'),62 +            _ => name.strip_prefix('#').and_then(|n| {63 +                let n = n.strip_prefix(['x', 'X']).map_or_else(64 +                    || u32::from_str_radix(n, 10).ok(),65 +                    |hex| u32::from_str_radix(hex, 16).ok(),66 +                );67 +                n.and_then(char::from_u32)68 +            }),69 +        };70 +        match decoded {71 +            Some(ch) => {72 +                out.push(ch);73 +                rest = &tail[semi + 1..];74 +            }75 +            None => {76 +                out.push('&');77 +                rest = tail;78 +            }79 +        }80 +    }81 +    out.push_str(rest);82 +    out83 +}84 +85 +/// `true` when `url` may be emitted into `href`/`src` (f0rge #62).86 +///87 +/// Only `http`, `https`, `mailto` and `tel` are allowed as schemes.88 +/// Relative URLs (no scheme) are allowed. `javascript:`, `data:`,89 +/// `vbscript:`, `file:` etc. are rejected — including when obfuscated90 +/// with entities, control characters or mixed case.91 +fn safe_url(url: &str) -> bool {92 +    let decoded = decode_entities(url);93 +    // Browsers strip tab/newline/CR and leading/trailing C0/space before94 +    // parsing the scheme.95 +    let cleaned: String = decoded96 +        .chars()97 +        .filter(|c| !c.is_ascii_control() && !c.is_whitespace())98 +        .collect();99 +    let lower = cleaned.to_ascii_lowercase();100 +    let Some(colon) = lower.find(':') else {101 +        return true; // no scheme: relative path102 +    };103 +    let scheme = &lower[..colon];104 +    if scheme.is_empty()105 +        || !scheme.chars().enumerate().all(|(i, c)| {106 +            c.is_ascii_alphabetic()107 +                || (i > 0 && (c.is_ascii_digit() || matches!(c, '+' | '-' | '.')))108 +        })109 +    {110 +        return true; // `./a:b`, `#x:y`, `?q=1:2` — not a scheme111 +    }112 +    matches!(scheme, "http" | "https" | "mailto" | "tel")113 +}114 +115  /// Render markdown to HTML, escaping raw HTML.116  ///117  /// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched118 -/// (R1 finding), so map them through [`esc`] first. Everything else119 -/// (code spans, emphasis, lists) renders normally.120 +/// (R1 finding), so map them through [`esc`] first. GFM tables, task121 +/// lists and strikethrough are enabled (f0rge #71); link/image122 +/// destinations are scheme-filtered (f0rge #62).123  #[must_use]124  pub fn markdown_to_html(md: &str) -> String {125 -    use pulldown_cmark::{html, Event, Options, Parser};126 -    let parser = Parser::new_ext(md, Options::empty());127 +    use pulldown_cmark::{html, Event, Options, Parser, Tag};128 +    let options = Options::ENABLE_TABLES129 +        | Options::ENABLE_TASKLISTS130 +        | Options::ENABLE_STRIKETHROUGH131 +        | Options::ENABLE_FOOTNOTES;132 +    let parser = Parser::new_ext(md, options);133      let mapped = parser.map(|ev| match ev {134          Event::Html(h) => Event::Html(esc(&h).into()),135          Event::InlineHtml(h) => Event::InlineHtml(esc(&h).into()),136 +        Event::Start(Tag::Link {137 +            link_type,138 +            dest_url,139 +            title,140 +            id,141 +        }) => {142 +            let dest = if safe_url(&dest_url) {143 +                decode_entities(&dest_url)144 +            } else {145 +                "#".to_string()146 +            };147 +            Event::Start(Tag::Link {148 +                link_type,149 +                dest_url: dest.into(),150 +                title,151 +                id,152 +            })153 +        }154 +        Event::Start(Tag::Image {155 +            link_type,156 +            dest_url,157 +            title,158 +            id,159 +        }) => {160 +            let dest = if safe_url(&dest_url) {161 +                decode_entities(&dest_url)162 +            } else {163 +                "#".to_string()164 +            };165 +            Event::Start(Tag::Image {166 +                link_type,167 +                dest_url: dest.into(),168 +                title,169 +                id,170 +            })171 +        }172          other => other,173      });174 -    let mut out = String::new();175 +    let mut out = String::from("<div class=\"markdown\">\n");176      html::push_html(&mut out, mapped);177 +    out.push_str("</div>\n");178      out179  }180  181 @@ -1748,6 +1874,58 @@ mod tests {182          );183      }184  185 +    #[test]186 +    fn markdown_enables_gfm_tables_tasks_strikethrough() {187 +        let md = "| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n\n~~gone~~\n";188 +        let html = markdown_to_html(md);189 +        let table = format!("<{}>", "table");190 +        assert!(html.contains(&table), "table parsed: {html}");191 +        assert!(html.contains("<th>a</th>"), "header cell");192 +        assert!(html.contains("<td>1</td>"), "body cell");193 +        assert!(html.contains("type=\"checkbox\""), "task checkbox");194 +        assert!(html.contains("checked"), "checked task preserved");195 +        assert!(html.contains("<li>"), "task list item");196 +        assert!(html.contains("<del>gone</del>"), "strikethrough");197 +    }198 +199 +    #[test]200 +    fn markdown_blocks_dangerous_link_schemes() {201 +        for evil in [202 +            "[x](javascript:alert(1))",203 +            "[x](JaVaScRiPt:alert(1))",204 +            "[x](java\tscript:alert(1))",205 +            "[x](javascript&colon;alert(1))",206 +            "[x](&#106;avascript:alert(1))",207 +            "[x](data:text/html;base64,PHNjcmlwdD4=)",208 +            "[x](vbscript:msgbox(1))",209 +            "[x](file:///etc/passwd)",210 +            "![i](javascript:alert(1))",211 +            "![i](data:image/svg+xml,<svg onload=alert(1)>)",212 +        ] {213 +            let html = markdown_to_html(evil);214 +            let lower = html.to_ascii_lowercase();215 +            for attr in ["href=", "src="] {216 +                for bad in ["javascript:", "vbscript:", "data:", "file:"] {217 +                    assert!(218 +                        !lower.contains(&format!("{attr}\"{bad}")),219 +                        "{evil:?} must not emit {attr}{bad}: {html}"220 +                    );221 +                }222 +            }223 +        }224 +    }225 +226 +    #[test]227 +    fn markdown_keeps_safe_links_and_relative_urls() {228 +        let md = "[a](https://example.com/x?y=1&z=2) [b](/files/x.html) [c](mailto:a@b) [d](tel:+1) [e](#L3)";229 +        let html = markdown_to_html(md);230 +        assert!(html.contains("href=\"https://example.com/x?y=1&amp;z=2\""));231 +        assert!(html.contains("href=\"/files/x.html\""));232 +        assert!(html.contains("href=\"mailto:a@b\""));233 +        assert!(html.contains("href=\"tel:+1\""));234 +        assert!(html.contains("href=\"#L3\""));235 +    }236 +237      #[test]238      fn human_size_formats() {239          assert_eq!(human_size(0), "0 B");240 diff --git a/static/style.css b/static/style.css241 index d764077..ea47b5f 100644242 --- a/static/style.css243 +++ b/static/style.css244 @@ -159,8 +159,8 @@ button:hover,input[type=submit]:hover{background:var(--cyan)}245  .btn-new:hover,.btn-new:focus{background:var(--cyan);text-decoration:none}246  .muted{color:var(--muted)}247  /* GFM markdown (f0rge #71): task lists, strikethrough, footnotes. */248 -.task-list-item{list-style:none}249 -.task-list-item input[type=checkbox]{margin:0 .4em 0 -1.2em;vertical-align:middle;accent-color:var(--green)}250 +li:has(>input[type=checkbox]){list-style:none}251 +.markdown input[type=checkbox],li>input[type=checkbox]{width:auto;height:auto;min-height:0;margin:0 .4em 0 -1.2em;vertical-align:middle;accent-color:var(--green)}252  .prose del,.del{color:var(--fg-dim);text-decoration:line-through}253  .footnotes{margin-top:var(--s5);font-size:var(--fs-sm);color:var(--fg-dim)}254  .footnotes ol{padding-left:var(--s5)}255 diff --git a/tests/responsive.rs b/tests/responsive.rs256 index bb72480..9c333b7 100644257 --- a/tests/responsive.rs258 +++ b/tests/responsive.rs259 @@ -430,9 +430,11 @@ fn forge_list_tables_scroll_and_wrap_long_titles() {260  #[test]261  fn zero_bare_tables_in_sources() {262      // Audit EVERY template (m6, f0rge issue #17): no `<table>` may open263 -    // outside a `.table-scroll` container. All tables are emitted from264 -    // src/*.rs render fns (markdown never emits tables), so grep the265 -    // sources: each line opening a table must carry the scroll wrapper.266 +    // outside a `.table-scroll` container. Hand-written tables are267 +    // emitted from src/*.rs render fns, so grep the sources: each line268 +    // opening a table must carry the scroll wrapper. (Markdown tables are269 +    // emitted by pulldown-cmark at runtime into `.markdown`, which wraps270 +    // by word instead of scrolling.)271      let mut checked = 0;272      let mut stack = vec![manifest().join("src")];273      while let Some(d) = stack.pop() {274 -- 275 2.56.0276