From ee6e71b2eb403bfa513cf2a3465991004a777c65 Mon Sep 17 00:00:00 2001 From: hu13-test Date: Tue, 27 Oct 2026 19:49:57 +0000 Subject: [PATCH] review #5: harden Pages headers, .forge symlinks, links, origin/static parity (#106-#117) --- .forge/wiki/deploy | 5 + docs/cloudflare.md | 12 +- functions/inbox/[kind].js | 25 +- src/build.rs | 262 ++++++++++++++++++-- src/forge.rs | 77 ++++-- src/paths.rs | 61 ++++- src/releases.rs | 36 ++- src/serve.rs | 21 +- src/wiki.rs | 15 +- tests/pages_inbox.test.mjs | 21 +- tests/review4_hardening.rs | 6 +- tests/review5_hardening.rs | 477 +++++++++++++++++++++++++++++++++++++ 12 files changed, 958 insertions(+), 60 deletions(-) create mode 100644 tests/review5_hardening.rs diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy index ac9b9d3..d13f21c 100644 --- a/.forge/wiki/deploy +++ b/.forge/wiki/deploy @@ -70,6 +70,11 @@ What happens inside (`entrypoint.sh`): `/releases//**` download policy for Pages. On nginx, add the equivalent `add_header Content-Disposition attachment` for `/raw/` and release artifact directories — committed HTML/SVG must never render. +- Pages clean-URL redirects: Pages answers `.html` paths with a 308 + (`/issues/index.html` → `/issues/`, `/files/a.md.html` → + `/files/a.md`); the links still resolve, and Docker/nginx serve the + exact path. `_redirects` status 200 cannot override this. If exact URL + shape matters, use a proxy/Worker or accept the Pages form. - Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and secret `INBOX_SALT` implement the same contract. See diff --git a/docs/cloudflare.md b/docs/cloudflare.md index b561c9e..7e6c420 100644 --- a/docs/cloudflare.md +++ b/docs/cloudflare.md @@ -4,6 +4,14 @@ The same `dist/` works on Docker/`hu13 serve`, nginx, and Pages. On Pages the inbox is implemented by a Pages Function backed by R2; a static-only upload has no working inbox. +Pages normalizes `.html` request paths with a 308 (for example +`/issues/index.html` → `/issues/` and `/files/a.md.html` → +`/files/a.md`). Every generated link still reaches the same page; the +redirect is a Pages platform behavior, not a missing file. Docker/nginx +serve the exact `.html` path with no redirect. If byte-identical URLs +matter, put a proxy/Worker in front or accept the Pages canonical form; +`_redirects` status 200 cannot override these clean-URL redirects. + ## 1. Build the site ```sh @@ -94,4 +102,6 @@ it does not read R2. Copy objects down if you want the terminal reader. - Committed `/raw/*` bytes and release artifacts under `/releases//*` always download (`Content-Disposition: attachment`) on both `serve` and Pages, so hostile HTML/SVG can never - execute in the inbox origin. + execute in the inbox origin. The Pages `_headers` rule uses one named + placeholder plus one splat (`/releases/:version/*`): Pages silently + skips rules with two splats. diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].js index 1fa9037..3ef6121 100644 --- a/functions/inbox/[kind].js +++ b/functions/inbox/[kind].js @@ -258,13 +258,17 @@ async function saltedIpHash(ip, salt) { // rule for /inbox/* is the durable control; this still stops a single // hot isolate from storing unlimited submissions. const buckets = new Map(); +const MAX_RATE_KEYS = 10000; function rateAllowed(key, limit) { const now = Date.now(); - if (buckets.size > 10000) { + if (buckets.size >= MAX_RATE_KEYS) { for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k); } let b = buckets.get(key); if (!b || b.reset <= now) { + // Fail closed for a new key when the isolate map is full; do not let + // source-IP rotation grow it without bound (f0rge #116). + if (!b && buckets.size >= MAX_RATE_KEYS) return false; b = { count: 0, reset: now + 3600_000 }; buckets.set(key, b); } @@ -273,9 +277,24 @@ function rateAllowed(key, limit) { return true; } +// R2 list returns at most 1000 objects per call. Paginate until the +// configured quota is counted or the listing ends; a truncated page +// without a cursor fails closed (f0rge #116). async function overQuota(bucket, limit) { - const listed = await bucket.list({ limit: Math.min(limit, 1000) }); - return listed.objects.length >= limit; + if (!(limit > 0)) return true; + let count = 0; + let cursor; + for (;;) { + const page = await bucket.list({ + limit: Math.min(limit - count, 1000), + cursor, + }); + count += page.objects.length; + if (count >= limit) return true; + if (!page.truncated) return false; + if (!page.cursor) return true; + cursor = page.cursor; + } } function nowRfc3339() { diff --git a/src/build.rs b/src/build.rs index 4274e88..a572829 100644 --- a/src/build.rs +++ b/src/build.rs @@ -191,14 +191,184 @@ fn safe_url(url: &str) -> bool { matches!(scheme, "http" | "https" | "mailto" | "tel") } -/// Render markdown to HTML, escaping raw HTML. -/// -/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched -/// (R1 finding), so map them through [`esc`] first. GFM tables, task -/// lists and strikethrough are enabled (f0rge #71); link/image -/// destinations are scheme-filtered (f0rge #62). -#[must_use] -pub fn markdown_to_html(md: &str) -> String { +/// Context for relative markdown destinations: a repo file page uses +/// `/files/...` + `/raw/...` + `/assets/...`; a wiki page uses +/// `/wiki/...` + raw/assets for non-page files. +#[derive(Clone, Copy)] +enum LinkBase<'a> { + Repo(&'a str), + Wiki(&'a str), +} + +fn base_path(base: LinkBase<'_>) -> &str { + match base { + LinkBase::Repo(p) | LinkBase::Wiki(p) => p, + } +} + +/// Lexically resolve a relative link against a repo- or wiki-relative +/// base directory. Returns `None` when it climbs above the root or has +/// unsafe/absolute components. +fn rel_join(base: &str, path: &str) -> Option { + if path.starts_with('/') || path.contains(['\\', '\0']) { + return None; + } + let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect(); + for seg in path.split('/') { + match seg { + "" | "." => {} + ".." => { + parts.pop()?; + } + other => parts.push(other), + } + } + if parts.is_empty() { + return None; + } + Some(parts.join("/")) +} + +/// Extensions safe to display inline from `assets/`: raster/static +/// images whose bytes cannot execute as a document. SVG and HTML stay +/// downloads under `raw/`. +fn is_inline_image(path: &str) -> bool { + let base = path.rsplit('/').next().unwrap_or(path); + let Some((stem, ext)) = base.rsplit_once('.') else { + return false; + }; + !stem.is_empty() + && matches!( + ext.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "avif" + ) +} + +/// Rewrite one relative markdown destination to the generated route. +/// External URLs, fragments, absolute paths and anything that cannot be +/// resolved stay as-is. Query/fragment suffixes are preserved. +fn rewrite_dest(dest: &str, base: LinkBase<'_>, image: bool) -> Option { + let d = dest.trim(); + if d.is_empty() || d.starts_with(['#', '/']) || d.starts_with("//") { + return None; + } + // A real scheme (`http:`, `mailto:`, ...) is not a relative file. + if let Some(colon) = d.find(':') { + let scheme = &d[..colon]; + let mut chars = scheme.chars(); + if chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return None; + } + } + let cut = d.find(['?', '#']).unwrap_or(d.len()); + let (path, suffix) = d.split_at(cut); + let target = rel_join(base_path(base), path)?; + let target = target.trim_end_matches('/'); + if target.is_empty() { + return None; + } + let enc = encode_path(target); + let asset = match base { + LinkBase::Wiki(_) => format!("/assets/.forge/wiki/{enc}"), + LinkBase::Repo(_) => format!("/assets/{enc}"), + }; + let route = if is_inline_image(target) { + asset + } else if image { + format!("/raw/{enc}") + } else { + match base { + LinkBase::Wiki(_) => format!("/wiki/{enc}.html"), + LinkBase::Repo(_) => { + let name = target.rsplit('/').next().unwrap_or(target); + if is_markdown(target) || name.eq_ignore_ascii_case("readme") { + format!("/files/{enc}.html") + } else { + format!("/raw/{enc}") + } + } + } + }; + Some(format!("{route}{suffix}")) +} + +/// Strip simple inline/block tags for a heading slug. Entity decoding +/// runs afterwards so `&` becomes a word separator, not `amp`. +fn heading_text(inner: &str) -> String { + let mut out = String::with_capacity(inner.len()); + let mut in_tag = false; + for c in inner.chars() { + match c { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => out.push(c), + _ => {} + } + } + decode_entities(&out) +} + +fn slugify(text: &str) -> String { + let mut out = String::new(); + let mut dash = false; + for c in text.chars().flat_map(char::to_lowercase) { + if c.is_alphanumeric() { + out.push(c); + dash = false; + } else if !out.is_empty() && !dash { + out.push('-'); + dash = true; + } + } + while out.ends_with('-') { + out.pop(); + } + if out.is_empty() { + "section".to_string() + } else { + out + } +} + +fn heading_parts(line: &str) -> Option<(usize, &str, &str)> { + let b = line.as_bytes(); + if b.len() < 8 || b[0] != b'<' || b[1] != b'h' || !(b'1'..=b'6').contains(&b[2]) || b[3] != b'>' + { + return None; + } + let level = usize::from(b[2] - b'0'); + let close = format!(""); + let end = line.find(&close)?; + Some((level, &line[4..end], &line[end + close.len()..])) +} + +/// Add deterministic heading ids so `#section` links work. Input is the +/// renderer's own output, so headings are one per line and contain only +/// generated tags; code-block lines are entity-escaped and never match. +fn add_heading_ids(html: &str) -> String { + let mut out = String::with_capacity(html.len() + 64); + let mut seen: BTreeMap = BTreeMap::new(); + for line in html.split_inclusive('\n') { + if let Some((level, inner, tail)) = heading_parts(line) { + let base = slugify(&heading_text(inner)); + let count = seen.entry(base.clone()).or_insert(0); + *count += 1; + let id = if *count == 1 { + base + } else { + format!("{base}-{}", *count) + }; + out.push_str(&format!("{inner}{tail}")); + } else { + out.push_str(line); + } + } + out +} + +fn render_markdown(md: &str, base: Option>) -> String { use pulldown_cmark::{html, Event, Options, Parser, Tag}; let options = Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS @@ -215,7 +385,11 @@ pub fn markdown_to_html(md: &str) -> String { id, }) => { let dest = if safe_url(&dest_url) { - decode_entities(&dest_url) + let decoded = decode_entities(&dest_url); + match base.and_then(|b| rewrite_dest(&decoded, b, false)) { + Some(rewritten) => rewritten, + None => decoded, + } } else { "#".to_string() }; @@ -233,7 +407,11 @@ pub fn markdown_to_html(md: &str) -> String { id, }) => { let dest = if safe_url(&dest_url) { - decode_entities(&dest_url) + let decoded = decode_entities(&dest_url); + match base.and_then(|b| rewrite_dest(&decoded, b, true)) { + Some(rewritten) => rewritten, + None => decoded, + } } else { "#".to_string() }; @@ -249,7 +427,31 @@ pub fn markdown_to_html(md: &str) -> String { let mut out = String::from("
\n"); html::push_html(&mut out, mapped); out.push_str("
\n"); - out + add_heading_ids(&out) +} + +/// Render markdown to HTML, escaping raw HTML. +/// +/// `pulldown-cmark` passes `Html`/`InlineHtml` events through untouched +/// (R1 finding), so map them through [`esc`] first. GFM tables, task +/// lists and strikethrough are enabled (f0rge #71); link/image +/// destinations are scheme-filtered (f0rge #62). Headings get stable +/// ids so `#section` links resolve (f0rge #112). +#[must_use] +pub fn markdown_to_html(md: &str) -> String { + render_markdown(md, None) +} + +/// Render markdown from a repo file page or wiki page, rewriting +/// relative links/images to generated routes (f0rge #112). +#[must_use] +pub fn markdown_to_html_in(md: &str, base: &str, wiki: bool) -> String { + let base = if wiki { + LinkBase::Wiki(base) + } else { + LinkBase::Repo(base) + }; + render_markdown(md, Some(base)) } /// Resolve the clone URL to advertise. @@ -547,6 +749,12 @@ pub fn now_unix() -> i64 { #[must_use] pub fn parse_ts_secs(s: &str) -> Option { let s = s.trim(); + // Timestamps are ASCII by definition. Reject other text before any + // byte-index slicing: a 200-char multibyte `created` used to panic + // on a non-char-boundary (f0rge #108). + if !s.is_ascii() { + return None; + } // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's // `2026-10-27 14:08:48 +0000` / `+00:00` forms. let (date, rest) = match s.split_once('T') { @@ -1154,7 +1362,10 @@ pub const HEADERS_FILE: &str = "\ X-Content-Type-Options: nosniff # Artifacts are build outputs copied from .forge/releases/out//; # never let an artifact .html/.svg execute in the site origin (f0rge #97). -/releases/*/* +# Cloudflare Pages allows one splat per rule. A named `:version` +# placeholder plus one splat covers every artifact depth (f0rge #106). +# A two-splat rule is silently skipped by Pages and leaves artifacts active. +/releases/:version/* Content-Disposition: attachment X-Content-Type-Options: nosniff /* @@ -1486,6 +1697,17 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result Resultview raw

\n", href_esc(f) @@ -1733,7 +1956,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> ResultGuide"), + html.contains("

Guide

"), "markdown rendered, got: {html}" ); assert!(html.contains("/raw/docs/guide.md"), "raw link kept"); @@ -2243,7 +2471,7 @@ mod tests { #[test] fn markdown_escapes_raw_html() { let html = markdown_to_html("# hi\n\n\n\n**bold**\n"); - assert!(html.contains("

hi

"), "heading renders"); + assert!(html.contains("

hi

"), "heading renders"); assert!(!html.contains("