diff --git a/.forge/prs/2b56eb82-e562-4fb3-818a-41694f31ef92/patch.diff b/.forge/prs/2b56eb82-e562-4fb3-818a-41694f31ef92/patch.diffnew file mode 100644index 0000000..de93ced--- /dev/null+++ b/.forge/prs/2b56eb82-e562-4fb3-818a-41694f31ef92/patch.diff@@ -0,0 +1,276 @@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+