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: and javascript: 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:…`, `javascript:…`).33 +///34 +/// `pulldown-cmark` writes `dest_url` into `href`/`src` and then escapes35 +/// it; a destination that still contains `:` 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:alert(1))",206 + "[x](javascript:alert(1))",207 + "[x](data:text/html;base64,PHNjcmlwdD4=)",208 + "[x](vbscript:msgbox(1))",209 + "[x](file:///etc/passwd)",210 + ")",211 + ">)",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&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