record #60 merge

67f5fb2gl0w

all commits

Message

record #60 merge

Diff

.forge/merges/2026-10-27T18-20-38.398508463Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T18-20-38.398508463Z.md b/.forge/merges/2026-10-27T18-20-38.398508463Z.mdnew file mode 100644index 0000000..c7d3e91--- /dev/null+++ b/.forge/merges/2026-10-27T18-20-38.398508463Z.md@@ -0,0 +1,9 @@1+---2+pr: e5ae0b6d-a0f9-4857-9fd3-7f9af558fc9d3+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T18:20:38.398508463Z7+---8+clean apply9+patch: 7740202434f8b74f916340e1c2c26b3b381d906f
.forge/prs/e5ae0b6d-a0f9-4857-9fd3-7f9af558fc9d/patch.diff+1001 −0
diff --git a/.forge/prs/e5ae0b6d-a0f9-4857-9fd3-7f9af558fc9d/patch.diff b/.forge/prs/e5ae0b6d-a0f9-4857-9fd3-7f9af558fc9d/patch.diffnew file mode 100644index 0000000..df4c5ea--- /dev/null+++ b/.forge/prs/e5ae0b6d-a0f9-4857-9fd3-7f9af558fc9d/patch.diff@@ -0,0 +1,1001 @@1+From f99cfc4a831b16c1c7350e1e3c60cd931857f2b2 Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 18:19:18 +00004+Subject: [PATCH] review #4: harden release artifacts, links, origin, CI5+ redaction (#97-#105)6+7+---8+ .forge/wiki/deploy         |   7 +-9+ docs/cloudflare.md         |  10 +-10+ functions/inbox/[kind].js  |  14 +-11+ src/build.rs               |  42 +++--12+ src/feed.rs                |  13 +-13+ src/forge.rs               |  10 +-14+ src/releases.rs            |  33 +++-15+ src/serve.rs               | 206 ++++++++++++++++++----16+ src/wiki.rs                |   6 +-17+ tests/pages_inbox.test.mjs |   8 +18+ tests/review4_hardening.rs | 344 +++++++++++++++++++++++++++++++++++++19+ 11 files changed, 620 insertions(+), 73 deletions(-)20+ create mode 100644 tests/review4_hardening.rs21+22+diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy23+index 1fd24c2..ac9b9d3 10064424+--- a/.forge/wiki/deploy25++++ b/.forge/wiki/deploy26+@@ -66,9 +66,10 @@ What happens inside (`entrypoint.sh`):27+   `/srv/www`) so the derived `repo.git` directory never looks tracked.28+ - Static hosting (Cloudflare Pages/nginx): publish the whole `dist/`29+   directory. `dist/repo.git` is plain files + `update-server-info`, and30+-  `dist/_headers` carries the `/raw/**` download policy for Pages. On31+-  nginx, add the equivalent `add_header Content-Disposition attachment`32+-  for `/raw/` — committed HTML/SVG must never render from that path.33++  `dist/_headers` carries the `/raw/**` and artifact34++  `/releases/<version>/**` download policy for Pages. On nginx, add the35++  equivalent `add_header Content-Disposition attachment` for `/raw/` and36++  release artifact directories — committed HTML/SVG must never render.37+ - Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The38+   Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and39+   secret `INBOX_SALT` implement the same contract. See40+diff --git a/docs/cloudflare.md b/docs/cloudflare.md41+index dedbd9d..b561c9e 10064442+--- a/docs/cloudflare.md43++++ b/docs/cloudflare.md44+@@ -12,8 +12,8 @@ cargo build --release45+   --public-origin https://hu13.example.com46+ ```47+ 48+-`--public-origin` makes the clone command, `og:image`, Atom links and49+-canonical URLs absolute. The build refuses output over Cloudflare's50++`--public-origin` makes the clone command, `og:image`, and Atom links51++absolute. The build refuses output over Cloudflare's52+ limits (20,000 files, 25 MiB/file) and writes `_headers` and53+ `_routes.json` into `dist/`.54+ 55+@@ -91,5 +91,7 @@ it does not read R2. Copy objects down if you want the terminal reader.56+   `dist/` (drag-and-drop or a plain static host) gives a dead inbox.57+ - `repo.git` is a dumb-HTTP repo: cloning works on any static host, no58+   smart server. No GitHub dependency.59+-- Committed `/raw/*` bytes always download (`Content-Disposition:60+-  attachment`) on both `serve` and Pages.61++- Committed `/raw/*` bytes and release artifacts under62++  `/releases/<version>/*` always download (`Content-Disposition:63++  attachment`) on both `serve` and Pages, so hostile HTML/SVG can never64++  execute in the inbox origin.65+diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].js66+index d7592b8..1fa9037 10064467+--- a/functions/inbox/[kind].js68++++ b/functions/inbox/[kind].js69+@@ -83,20 +83,26 @@ function sleep(ms) {70+ }71+ 72+ // Same-origin gate: browsers send Origin/Referer; agents (curl) send73+-// neither and pass. `null`/unparsable/mismatched hosts fail closed.74++// neither and pass. `null`/unparsable/mismatched host or scheme fails75++// closed; `.host` keeps the port, so another local port is rejected.76+ function originAllowed(request) {77+-  let host;78++  let target;79+   try {80+-    host = new URL(request.url).host.toLowerCase();81++    target = new URL(request.url);82+   } catch {83+     return false;84+   }85++  const host = target.host.toLowerCase();86++  const protocol = target.protocol;87+   for (const name of ["origin", "referer"]) {88+     const value = request.headers.get(name);89+     if (value === null) continue;90+     if (!value || value.trim().toLowerCase() === "null") return false;91+     try {92+-      if (new URL(value).host.toLowerCase() !== host) return false;93++      const claimed = new URL(value);94++      if (claimed.host.toLowerCase() !== host || claimed.protocol !== protocol) {95++        return false;96++      }97+     } catch {98+       return false;99+     }100+diff --git a/src/build.rs b/src/build.rs101+index 532802d..4274e88 100644102+--- a/src/build.rs103++++ b/src/build.rs104+@@ -415,7 +415,7 @@ pub fn is_markdown(path: &str) -> bool {105+ /// browser truncates a link at `#`/`?` (f0rge #89). `/` stays literal106+ /// so file paths remain hierarchical; everything outside the unreserved107+ /// set becomes `%XX` of its UTF-8 bytes.108+-fn encode_path(path: &str) -> String {109++pub(crate) fn encode_path(path: &str) -> String {110+     let mut out = String::with_capacity(path.len());111+     for &b in path.as_bytes() {112+         if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {113+@@ -427,7 +427,7 @@ fn encode_path(path: &str) -> String {114+     out115+ }116+ 117+-fn href_esc(path: &str) -> String {118++pub(crate) fn href_esc(path: &str) -> String {119+     esc(&encode_path(path))120+ }121+ 122+@@ -1148,10 +1148,15 @@ pub const ROUTES_FILE: &str =123+ /// inbox endpoints live. `serve` sets the same policy directly124+ /// (f0rge #61); this file gives Pages the equivalent.125+ pub const HEADERS_FILE: &str = "\126+-# generated by hu13: committed files are untrusted (f0rge #61)127++# generated by hu13: committed files and release artifacts are untrusted128+ /raw/*129+   Content-Disposition: attachment130+   X-Content-Type-Options: nosniff131++# Artifacts are build outputs copied from .forge/releases/out/<ver>/;132++# never let an artifact .html/.svg execute in the site origin (f0rge #97).133++/releases/*/*134++  Content-Disposition: attachment135++  X-Content-Type-Options: nosniff136+ /*137+   X-Content-Type-Options: nosniff138+   Referrer-Policy: strict-origin-when-cross-origin139+@@ -1324,14 +1329,29 @@ fn absolutize_social_meta(out: &Path, origin: &str) -> Result<(), String> {140+             let path = ent.path();141+             if path.is_dir() {142+                 stack.push(path);143+-            } else if path.extension().is_some_and(|x| x == "html") {144+-                let html = std::fs::read_to_string(&path)145+-                    .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;146+-                let replaced = html.replace("content=\"/og.png\"", &absolute);147+-                if replaced != html {148+-                    std::fs::write(&path, replaced)149+-                        .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;150+-                }151++                continue;152++            }153++            if !path.extension().is_some_and(|x| x == "html") {154++                continue;155++            }156++            let rel = path.strip_prefix(out).unwrap_or(&path);157++            let rel = rel.to_string_lossy().replace('\\', "/");158++            // Only generated pages carry this marker. `/raw/` and release159++            // artifacts are byte-for-byte copies of untrusted inputs: reading160++            // them as UTF-8 can fail the build and rewriting them corrupts the161++            // download (f0rge #100). `repo.git` is git internals, never HTML.162++            if rel.starts_with("raw/")163++                || rel.starts_with("repo.git/")164++                || crate::releases::is_artifact_path(&rel)165++            {166++                continue;167++            }168++            let html = std::fs::read_to_string(&path)169++                .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;170++            let replaced = html.replace("content=\"/og.png\"", &absolute);171++            if replaced != html {172++                std::fs::write(&path, replaced)173++                    .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;174+             }175+         }176+     }177+diff --git a/src/feed.rs b/src/feed.rs178+index 24be85d..f50a956 100644179+--- a/src/feed.rs180++++ b/src/feed.rs181+@@ -7,6 +7,7 @@182+ 183+ use std::path::Path;184+ 185++use crate::build::encode_path;186+ use crate::gitx;187+ use crate::releases;188+ 189+@@ -89,17 +90,15 @@ pub fn read_release_entries(repo: &Path) -> Vec<FeedEntry> {190+             let summary = if first_line.is_empty() {191+                 format!("release {} ({})", r.version, r.created)192+             } else {193+-                let mut s: String = first_line.chars().take(200).collect();194+-                if s.len() > 200 {195+-                    s.truncate(200);196+-                }197+-                s198++                // 200 Unicode scalar values, never a byte offset: a199++                // multibyte cut used to panic in `truncate` (f0rge #99).200++                first_line.chars().take(200).collect()201+             };202+             FeedEntry {203+                 title: r.display_title().to_string(),204+-                id: format!("urn:release:{}", r.version),205++                id: format!("urn:release:{}", encode_path(&r.version)),206+                 updated: r.created.clone(),207+-                link: format!("/releases/{}.html", r.version),208++                link: format!("/releases/{}.html", encode_path(&r.version)),209+                 summary,210+             }211+         })212+diff --git a/src/forge.rs b/src/forge.rs213+index c9f7ce6..ecdbcba 100644214+--- a/src/forge.rs215++++ b/src/forge.rs216+@@ -770,10 +770,12 @@ fn load_milestones(root: &Path) -> Vec<Milestone> {217+ /// the canonical repo root with `<repo>`, then `$HOME` with `~`.218+ fn redact_paths(text: &str, root: &Path) -> String {219+     let mut out = text.to_string();220+-    let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());221+-    for p in [root_abs.as_path(), root] {222+-        let needle = p.to_string_lossy();223+-        if !needle.is_empty() && needle != "/" {224++    // Only replace a canonical absolute path. `root` may be `.forge` under a225++    // relative repo argument (`.`); using the raw relative form as a needle226++    // would replace every dot in `v0.1.0`/`00.log` (f0rge #103).227++    if let Ok(root_abs) = root.canonicalize() {228++        let needle = root_abs.to_string_lossy();229++        if !needle.is_empty() && needle != "/" && needle != "." && needle != ".." {230+             out = out.replace(needle.as_ref(), "<repo>");231+         }232+     }233+diff --git a/src/releases.rs b/src/releases.rs234+index 4ef2d1b..75acf0c 100644235+--- a/src/releases.rs236++++ b/src/releases.rs237+@@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};238+ 239+ use serde::Deserialize;240+ 241+-use crate::build::markdown_to_html;242++use crate::build::{href_esc, markdown_to_html};243+ use crate::layout::{esc, page_site_with_provenance};244+ 245+ /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).246+@@ -326,8 +326,21 @@ pub fn sha256_lines(body: &str) -> Vec<String> {247+         .collect()248+ }249+ 250++/// True for `releases/<version>/...` artifact paths (but not the generated251++/// `releases/<version>.html` page). Both the server and the Pages header252++/// policy use this to keep artifacts downloads, never active documents.253++#[must_use]254++pub fn is_artifact_path(path: &str) -> bool {255++    let mut parts = path.split('/');256++    matches!(parts.next(), Some("releases"))257++        && matches!(parts.next(), Some(version) if !version.is_empty())258++        && parts.next().is_some()259++}260++261+ fn artifact_href(version: &str, path: &str) -> String {262+-    format!("/releases/{}/{path}", esc(version))263++    // Version and artifact path are repo-controlled: percent-encode both264++    // (`#`/`?`/quotes) before HTML-escaping the attribute (f0rge #98/#104).265++    href_esc(&format!("/releases/{version}/{path}"))266+ }267+ 268+ fn render_release_page(269+@@ -422,8 +435,9 @@ pub fn render_loaded(270+     } else {271+         body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>version</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");272+         for rel in releases {273++            let href = href_esc(&format!("/releases/{}.html", rel.version));274+             body.push_str(&format!(275+-                "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{t}\">{t}</td><td class=\"trunc\" data-label=\"version\" title=\"{v}\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"trunc\" data-label=\"created\" title=\"{c}\">{ca}</td><td class=\"trunc\" data-label=\"artifacts\" title=\"{n}\">{n}</td></tr>\n",276++                "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{t}\">{t}</td><td class=\"trunc\" data-label=\"version\" title=\"{v}\"><a href=\"{href}\">{ve}</a></td><td class=\"trunc\" data-label=\"created\" title=\"{c}\">{ca}</td><td class=\"trunc\" data-label=\"artifacts\" title=\"{n}\">{n}</td></tr>\n",277+                 v = esc(&rel.version),278+                 ve = esc(&rel.version),279+                 t = esc(rel.display_title()),280+@@ -490,6 +504,19 @@ mod tests {281+         assert!(sha256_lines("no hashes here\n").is_empty());282+     }283+ 284++    #[test]285++    fn artifact_paths_and_links_are_classified_and_encoded() {286++        assert!(is_artifact_path("releases/v1.0/app.bin"));287++        assert!(is_artifact_path("releases/v1.0/nested/app.bin"));288++        assert!(!is_artifact_path("releases/v1.0.html"));289++        assert!(!is_artifact_path("releases/index.html"));290++        assert!(!is_artifact_path("raw/releases/v1/app.bin"));291++292++        let href = artifact_href("v1#x", "a\" b#c?.txt");293++        assert!(href.starts_with("/releases/v1%23x/a%22%20b%23c%3F.txt"));294++        assert!(!href.contains('"'), "quotes are encoded");295++    }296++297+     #[test]298+     fn display_title_falls_back_to_version() {299+         let r = Release {300+diff --git a/src/serve.rs b/src/serve.rs301+index 85df371..9edb446 100644302+--- a/src/serve.rs303++++ b/src/serve.rs304+@@ -170,11 +170,36 @@ fn absolutize(p: &Path) -> PathBuf {305+     }306+ }307+ 308++/// Resolve symlinks in the deepest existing ancestor of `p`, then append309++/// the non-existent suffix. Startup may run before the inbox dir exists.310++fn resolve_existing(p: &Path) -> PathBuf {311++    let abs = absolutize(p);312++    let mut suffix: Vec<std::ffi::OsString> = Vec::new();313++    let mut cur = abs.as_path();314++    loop {315++        if let Ok(mut resolved) = std::fs::canonicalize(cur) {316++            for name in suffix.iter().rev() {317++                resolved.push(name);318++            }319++            return resolved;320++        }321++        let Some(name) = cur.file_name() else {322++            return abs;323++        };324++        suffix.push(name.to_os_string());325++        let Some(parent) = cur.parent() else {326++            return abs;327++        };328++        cur = parent;329++    }330++}331++332+ /// Refuse to start when the inbox dir is inside (or equal to) www.333+-/// Inbox files must never be reachable over HTTP.334++/// Inbox files must never be reachable over HTTP. Resolve symlinks: a335++/// lexical-only check lets `--inbox-dir link-to-www/private` through.336+ pub fn ensure_inbox_outside_www(www: &Path, inbox_dir: &Path) -> Result<(), String> {337+-    let www = absolutize(www);338+-    let inbox = absolutize(inbox_dir);339++    let www = resolve_existing(www);340++    let inbox = resolve_existing(inbox_dir);341+     if inbox == www || inbox.starts_with(&www) {342+         return Err(format!(343+             "hu13: refusing to start: inbox-dir {} is inside www {} (inbox must never be served)",344+@@ -216,6 +241,12 @@ pub fn run(opts: &ServeOptions) -> i32 {345+             return 1;346+         }347+     }348++    // Re-check after creation: a dangling symlink may only reveal that its349++    // target is inside www once `create_dir_all` materializes it.350++    if let Err(e) = ensure_inbox_outside_www(&opts.www, &opts.inbox_dir) {351++        eprintln!("{e}");352++        return 1;353++    }354+     let listener = match TcpListener::bind(&opts.listen) {355+         Ok(l) => l,356+         Err(e) => {357+@@ -451,6 +482,16 @@ fn content_type_for(name: &str) -> &'static str {358+         "text/css; charset=utf-8"359+     } else if lower.ends_with(".svg") {360+         "image/svg+xml"361++    } else if lower.ends_with(".png") {362++        "image/png"363++    } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {364++        "image/jpeg"365++    } else if lower.ends_with(".gif") {366++        "image/gif"367++    } else if lower.ends_with(".webp") {368++        "image/webp"369++    } else if lower.ends_with(".ico") {370++        "image/x-icon"371+     } else if lower.ends_with(".webmanifest") {372+         "application/manifest+json"373+     } else if lower.ends_with(".json") {374+@@ -593,48 +634,77 @@ fn decode_path(s: &str) -> Option<String> {375+     String::from_utf8(bytes).ok()376+ }377+ 378+-/// Hostname of an `Origin`/`Referer` value (`scheme://host[:port][/...]`)379+-/// or of a `Host` header (`host[:port]`), lowercased, brackets stripped.380+-/// `None` = unparsable (fail closed when it came from the browser).381+-fn host_of_url(value: &str) -> Option<String> {382++/// Normalized authority (`host` + effective port) of an `Origin`/`Referer`383++/// URL (`scheme://host[:port][/...]`) or a `Host` header (`host[:port]`).384++/// Scheme defaults matter: `http` → 80, `https` → 443, and a bare `Host`385++/// on this plain-HTTP server → 80. `None` = unparsable/unsupported → fail386++/// closed when supplied by the browser.387++#[derive(Debug, PartialEq, Eq)]388++struct Authority {389++    scheme: String,390++    host: String,391++    port: u16,392++}393++394++fn authority_of_url(value: &str, default_scheme: &str) -> Option<Authority> {395+     let v = value.trim();396+-    if (v.is_empty()) || v.eq_ignore_ascii_case("null") {397++    if v.is_empty() || v.eq_ignore_ascii_case("null") {398+         return None;399+     }400+-    let after_scheme = match v.split_once("://") {401+-        Some((_, rest)) => rest,402+-        None => v,403++    let (scheme, rest) = match v.split_once("://") {404++        Some((scheme, rest)) => {405++            let scheme = scheme.to_ascii_lowercase();406++            if scheme != "http" && scheme != "https" {407++                return None;408++            }409++            (scheme, rest)410++        }411++        // A bare `Host` has no scheme; use the request scheme (plain HTTP,412++        // or `X-Forwarded-Proto` when behind a TLS proxy).413++        None => (default_scheme.to_string(), v),414+     };415+-    let hostport = after_scheme.split('/').next().unwrap_or("");416++    let hostport = rest.split('/').next().unwrap_or("");417+     let hostport = hostport.split('@').next_back().unwrap_or("");418+     if hostport.is_empty() {419+         return None;420+     }421+-    // Split port (last `:` with only digits after, or `[v6]:port`).422+-    let host = if let Some(stripped) = hostport.strip_prefix('[') {423+-        match stripped.split_once(']') {424+-            Some((h, _)) => h,425+-            None => return None,426+-        }427++    let (host, port) = if let Some(stripped) = hostport.strip_prefix('[') {428++        let (host, after) = stripped.split_once(']')?;429++        let port = if after.is_empty() {430++            None431++        } else {432++            Some(after.strip_prefix(':')?)433++        };434++        (host, port)435+     } else if hostport.matches(':').count() == 1 {436+-        match hostport.split_once(':') {437+-            Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,438+-            Some(_) => return None,439+-            None => hostport,440++        let (host, port) = hostport.split_once(':')?;441++        if port.is_empty() {442++            return None;443+         }444++        (host, Some(port))445+     } else {446+-        hostport447++        (hostport, None)448+     };449+     let host = host.trim().trim_matches('.').to_ascii_lowercase();450+     if host.is_empty() {451+         return None;452+     }453+-    Some(host)454++    let port = match port {455++        Some(port) => port.parse::<u16>().ok()?,456++        None => match scheme.as_str() {457++            "https" => 443,458++            "http" => 80,459++            _ => return None,460++        },461++    };462++    Some(Authority { scheme, host, port })463+ }464+ 465+ /// Same-origin gate for the two POST routes: no `Origin` and no466+ /// `Referer` (curl/agents) → allow; browser-supplied `Origin` (else467+-/// `Referer`) must match the `Host` hostname → else `403`. Never echoes.468++/// `Referer`) must match the `Host` authority, including port → else469++/// `403`. Comparing only hostnames lets another local port forge a470++/// same-origin request (f0rge #101). Never echoes.471+ #[must_use]472+ fn origin_allowed(head: &Head) -> bool {473+     let origin = header(head, "origin");474+@@ -649,9 +719,15 @@ fn origin_allowed(head: &Head) -> bool {475+     let Some(claimed) = claimed else {476+         return true; // curl sends none477+     };478++    let scheme = header(head, "x-forwarded-proto")479++        .and_then(|v| v.split(',').next())480++        .map(str::trim)481++        .map(str::to_ascii_lowercase)482++        .filter(|v| v == "http" || v == "https")483++        .unwrap_or_else(|| "http".to_string());484+     let (Some(want), Some(got)) = (485+-        header(head, "host").and_then(host_of_url),486+-        host_of_url(claimed),487++        header(head, "host").and_then(|h| authority_of_url(h, &scheme)),488++        authority_of_url(claimed, &scheme),489+     ) else {490+         return false; // unparsable Host or Origin → fail closed491+     };492+@@ -706,8 +782,9 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {493+     } else {494+         rel.to_string()495+     };496+-    let dest = absolutize(&shared.www.join(&file_rel));497+-    if dest != absolutize(&shared.www) && !dest.starts_with(absolutize(&shared.www)) {498++    let www = resolve_existing(&shared.www);499++    let dest = resolve_existing(&shared.www.join(&file_rel));500++    if dest != www && !dest.starts_with(&www) {501+         static_not_found(&mut stream, &head, shared);502+         return;503+     }504+@@ -726,13 +803,21 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {505+         static_error(&mut stream, 405);506+         return;507+     }508+-    let is_raw = file_rel.starts_with("raw/");509+-    let (ctype, disposition) = if is_raw {510+-        raw_content_type(&file_rel)511++    // Classify by the resolved path, not the raw request string: a request512++    // like `/releases//v1/evil.html` or `/./releases/...` maps to the same513++    // file but would bypass a string-prefix check (f0rge #97).514++    let served_rel = dest515++        .strip_prefix(&www)516++        .map(|p| p.to_string_lossy().replace('\\', "/"))517++        .unwrap_or_else(|_| file_rel.clone());518++    let is_raw = served_rel.starts_with("raw/");519++    let is_artifact = crate::releases::is_artifact_path(&served_rel);520++    let (ctype, disposition) = if is_raw || is_artifact {521++        raw_content_type(&served_rel)522+     } else {523+-        (content_type_for(&file_rel), "inline")524++        (content_type_for(&served_rel), "inline")525+     };526+-    let extra: &[(&str, &str)] = if is_raw {527++    let extra: &[(&str, &str)] = if is_raw || is_artifact {528+         &[("Content-Disposition", disposition)]529+     } else {530+         &[]531+@@ -1195,6 +1280,9 @@ mod tests {532+         assert!(content_type_for("f.atom").contains("atom"));533+         // m6 favicon (f0rge issue #16): svg icon + web app manifest.534+         assert_eq!(content_type_for("favicon.svg"), "image/svg+xml");535++        assert_eq!(content_type_for("og.png"), "image/png");536++        assert_eq!(content_type_for("photo.JPG"), "image/jpeg");537++        assert_eq!(content_type_for("icon.ico"), "image/x-icon");538+         assert_eq!(539+             content_type_for("manifest.webmanifest"),540+             "application/manifest+json"541+@@ -1202,6 +1290,35 @@ mod tests {542+         assert_eq!(content_type_for("b.bundle"), "application/octet-stream");543+     }544+ 545++    #[cfg(unix)]546++    #[test]547++    fn inbox_symlink_into_www_refused() {548++        use std::os::unix::fs::symlink;549++        let base = std::env::temp_dir().join(format!(550++            "hu13-inbox-symlink-{}-{}",551++            std::process::id(),552++            std::time::SystemTime::now()553++                .duration_since(std::time::UNIX_EPOCH)554++                .map(|d| d.as_nanos())555++                .unwrap_or(0)556++        ));557++        let www = base.join("www");558++        let private = www.join("private");559++        std::fs::create_dir_all(&private).unwrap();560++        let inbox = base.join("inbox-link");561++        symlink(&private, &inbox).unwrap();562++        assert!(ensure_inbox_outside_www(&www, &inbox).is_err());563++564++        let parent = base.join("linked-parent");565++        symlink(&www, &parent).unwrap();566++        assert!(ensure_inbox_outside_www(&www, &parent.join("inbox")).is_err());567++568++        let www_link = base.join("www-link");569++        symlink(&www, &www_link).unwrap();570++        assert!(ensure_inbox_outside_www(&www_link, &www.join("inbox")).is_err());571++        std::fs::remove_dir_all(&base).ok();572++    }573++574+     #[test]575+     fn bad_paths_rejected() {576+         assert!(decode_path("/%2e%2e/x").is_some()); // decodes; `..` blocked later577+@@ -1238,6 +1355,27 @@ mod tests {578+             Some("https://evil.example"),579+             None580+         )));581++        // Different port or scheme is a different origin (f0rge #101).582++        assert!(!origin_allowed(&head_with(583++            "127.0.0.1:39313",584++            Some("http://127.0.0.1:9999"),585++            None586++        )));587++        assert!(!origin_allowed(&head_with(588++            "127.0.0.1:39313",589++            Some("https://127.0.0.1:39313"),590++            None591++        )));592++        assert!(!origin_allowed(&head_with(593++            "a.example:443",594++            Some("https://a.example"),595++            None596++        )));597++        let mut proxied = head_with("a.example", Some("https://a.example"), None);598++        proxied599++            .headers600++            .push(("x-forwarded-proto".to_string(), "https".to_string()));601++        assert!(origin_allowed(&proxied), "TLS proxy can declare https");602+         assert!(!origin_allowed(&head_with(603+             "127.0.0.1:1",604+             Some("null"),605+diff --git a/src/wiki.rs b/src/wiki.rs606+index a0e8d7e..25bfc48 100644607+--- a/src/wiki.rs608++++ b/src/wiki.rs609+@@ -14,7 +14,7 @@610+ use std::collections::BTreeMap;611+ use std::path::Path;612+ 613+-use crate::build::markdown_to_html;614++use crate::build::{href_esc, markdown_to_html};615+ use crate::layout::{esc, page_site_with_provenance};616+ 617+ /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).618+@@ -288,9 +288,9 @@ pub fn render(619+             } else {620+                 String::new()621+             };622++            let href = href_esc(&format!("/wiki/{}.html", p.path));623+             body.push_str(&format!(624+-                "<li><a href=\"/wiki/{}.html\">{}</a>{hint}</li>\n",625+-                esc(&p.path),626++                "<li><a href=\"{href}\">{}</a>{hint}</li>\n",627+                 esc(&p.title),628+             ));629+         }630+diff --git a/tests/pages_inbox.test.mjs b/tests/pages_inbox.test.mjs631+index f9625c8..a825c71 100644632+--- a/tests/pages_inbox.test.mjs633++++ b/tests/pages_inbox.test.mjs634+@@ -128,6 +128,14 @@ test("cross-origin browser POST is rejected; curl (no Origin) passes", async ()635+   assert.equal(bad.res.status, 403);636+   const nullOrigin = await post("issue", validIssue, { origin: "null" });637+   assert.equal(nullOrigin.res.status, 403);638++  const badPort = await post("issue", validIssue, {639++    origin: "https://hu13.test:444",640++  });641++  assert.equal(badPort.res.status, 403);642++  const badScheme = await post("issue", validIssue, {643++    origin: "http://hu13.test",644++  });645++  assert.equal(badScheme.res.status, 403);646+   const same = await post("issue", validIssue, { origin: "https://hu13.test" });647+   assert.equal(same.res.status, 200);648+ });649+diff --git a/tests/review4_hardening.rs b/tests/review4_hardening.rs650+new file mode 100644651+index 0000000..961f0bc652+--- /dev/null653++++ b/tests/review4_hardening.rs654+@@ -0,0 +1,344 @@655++//! Review #4 hardening (f0rge #97–#105): release artifacts are downloads,656++//! artifact/feed/wiki URLs are encoded, social-meta postprocessing skips657++//! copied bytes, origin checks include port/scheme, and relative CI logs658++//! keep their dots.659++660++use std::io::{BufRead, BufReader, Read, Write};661++use std::net::TcpStream;662++use std::path::{Path, PathBuf};663++use std::process::{Child, Command, Stdio};664++use std::sync::atomic::{AtomicU64, Ordering};665++use std::time::Duration;666++667++static SEQ: AtomicU64 = AtomicU64::new(0);668++669++fn fresh(prefix: &str) -> PathBuf {670++    let dir = std::env::temp_dir().join(format!(671++        "hu13-review4-{prefix}-{}-{}",672++        std::process::id(),673++        std::time::SystemTime::now()674++            .duration_since(std::time::UNIX_EPOCH)675++            .map(|d| d.as_nanos())676++            .unwrap_or(0)677++            .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))678++    ));679++    std::fs::create_dir_all(&dir).unwrap();680++    dir681++}682++683++fn git(repo: &Path, args: &[&str]) {684++    let st = Command::new("git")685++        .arg("-C")686++        .arg(repo)687++        .args(args)688++        .status()689++        .expect("spawn git");690++    assert!(st.success(), "git {args:?} failed");691++}692++693++fn write(repo: &Path, rel: &str, body: &str) {694++    let dest = repo.join(rel);695++    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();696++    std::fs::write(dest, body).unwrap();697++}698++699++fn write_bytes(repo: &Path, rel: &str, body: &[u8]) {700++    let dest = repo.join(rel);701++    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();702++    std::fs::write(dest, body).unwrap();703++}704++705++fn read_bytes(path: &Path) -> Vec<u8> {706++    std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))707++}708++709++fn read(path: &Path) -> String {710++    String::from_utf8(read_bytes(path)).expect("utf8")711++}712++713++const VERSION: &str = "v1#x";714++const ARTIFACT_EVIL: &str = "evil\" onmouseover=\"alert(1).html";715++const ARTIFACT_HASH: &str = "hash#q?%.txt";716++717++fn fixture() -> PathBuf {718++    let repo = fresh("repo");719++    git(&repo, &["init", "-b", "main"]);720++    git(&repo, &["config", "user.name", "hu13-test"]);721++    git(&repo, &["config", "user.email", "[email protected]"]);722++    git(&repo, &["config", "commit.gpgsign", "false"]);723++    write(&repo, "README.md", "# review4 fixture\n");724++    write(725++        &repo,726++        "raw/x.html",727++        "<meta property=\"og:image\" content=\"/og.png\">",728++    );729++    write_bytes(&repo, "raw/bad.html", &[0xff, 0xfe, b'<', b'x', b'>']);730++    write(731++        &repo,732++        ".forge/releases/v1#x.md",733++        &format!(734++            "---\nversion: \"{VERSION}\"\ntitle: Frag\ncreated: 2026-09-10T02:00:00Z\n---\n{}\nnotes\n",735++            "€".repeat(200)736++        ),737++    );738++    let artifact_dir = format!(".forge/releases/out/{VERSION}");739++    write(740++        &repo,741++        &format!("{artifact_dir}/{ARTIFACT_EVIL}"),742++        "<script>alert(1)</script>",743++    );744++    write(&repo, &format!("{artifact_dir}/{ARTIFACT_HASH}"), "hash\n");745++    write(746++        &repo,747++        &format!("{artifact_dir}/artifact.html"),748++        "<meta property=\"og:image\" content=\"/og.png\">",749++    );750++    write(&repo, ".forge/wiki/a#b.md", "# Hash wiki\n\nbody\n");751++    write(752++        &repo,753++        ".forge/ci/runs/2026-09-10T00-00-00Z/summary.md",754++        "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n  - name: 00_test.sh\n    exit: 0\n    duration_ms: 3\n---\nsummary\n",755++    );756++    write(757++        &repo,758++        ".forge/ci/runs/2026-09-10T00-00-00Z/steps/00_test.sh.log",759++        &format!("Checking hu13 v0.1.0 at {}\n", repo.display()),760++    );761++    git(&repo, &["add", "-A"]);762++    git(&repo, &["commit", "-m", "review4 fixture"]);763++    repo764++}765++766++fn build(repo: &Path, out: &Path) -> std::process::Output {767++    Command::new(env!("CARGO_BIN_EXE_hu13"))768++        .arg("build")769++        .arg(repo)770++        .arg("-o")771++        .arg(out)772++        .arg("--public-origin")773++        .arg("https://hu13.example")774++        .output()775++        .expect("spawn hu13 build")776++}777++778++struct Server {779++    child: Child,780++    addr: String,781++}782++783++impl Drop for Server {784++    fn drop(&mut self) {785++        self.child.kill().ok();786++        self.child.wait().ok();787++    }788++}789++790++fn start(www: &Path, inbox: &Path, extra: &[&str]) -> Server {791++    let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));792++    cmd.arg("serve")793++        .arg("--www")794++        .arg(www)795++        .arg("--inbox-dir")796++        .arg(inbox)797++        .arg("--listen")798++        .arg("127.0.0.1:0")799++        .args(extra)800++        .stdout(Stdio::piped())801++        .stderr(Stdio::null());802++    let mut child = cmd.spawn().expect("spawn serve");803++    let stdout = child.stdout.take().expect("stdout");804++    let mut addr = String::new();805++    for line in BufReader::new(stdout).lines().take(50).flatten() {806++        if let Some(rest) = line.split("listening on ").nth(1) {807++            addr = rest.trim().to_string();808++            break;809++        }810++    }811++    assert!(!addr.is_empty(), "serve printed no listening line");812++    Server { child, addr }813++}814++815++fn get(addr: &str, path: &str) -> String {816++    let mut s = TcpStream::connect(addr).expect("connect");817++    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();818++    write!(819++        s,820++        "GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"821++    )822++    .unwrap();823++    let mut out = Vec::new();824++    s.read_to_end(&mut out).unwrap();825++    String::from_utf8_lossy(&out).into_owned()826++}827++828++fn post(addr: &str, origin: Option<&str>) -> String {829++    let body = "title=Hello&body=World&license=agree";830++    let origin_line = origin831++        .map(|o| format!("Origin: {o}\r\n"))832++        .unwrap_or_default();833++    let req = format!(834++        "POST /inbox/issue HTTP/1.1\r\nHost: {addr}\r\n{origin_line}Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",835++        body.len()836++    );837++    let mut s = TcpStream::connect(addr).expect("connect");838++    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();839++    s.write_all(req.as_bytes()).unwrap();840++    let mut out = Vec::new();841++    s.read_to_end(&mut out).unwrap();842++    String::from_utf8_lossy(&out).into_owned()843++}844++845++#[test]846++fn review4_build_and_release_hardening() {847++    let repo = fixture();848++    let out = fresh("out");849++    let res = build(&repo, &out);850++    assert!(851++        res.status.success(),852++        "build failed: {}",853++        String::from_utf8_lossy(&res.stderr)854++    );855++856++    let release = read(&out.join(format!("releases/{VERSION}.html")));857++    assert!(858++        release.contains("href=\"/releases/v1%23x/evil%22%20onmouseover"),859++        "{release}"860++    );861++    assert!(!release.contains("onmouseover=\"alert(1)\""));862++    assert!(863++        release.contains("hash%23q%3F%25.txt"),864++        "artifact path encoded"865++    );866++867++    let index = read(&out.join("releases/index.html"));868++    assert!(index.contains("href=\"/releases/v1%23x.html\""), "{index}");869++    let wiki = read(&out.join("wiki/index.html"));870++    assert!(wiki.contains("href=\"/wiki/a%23b.md.html\""), "{wiki}");871++    let feed = read(&out.join("feed.atom"));872++    assert!(feed.contains("/releases/v1%23x.html"), "{feed}");873++    assert!(874++        feed.contains(&"€".repeat(200)),875++        "multibyte summary truncation must not panic or corrupt text"876++    );877++878++    let headers = read(&out.join("_headers"));879++    assert!(headers.contains("/releases/*/*"), "{headers}");880++    assert!(881++        headers.contains("Content-Disposition: attachment"),882++        "{headers}"883++    );884++885++    // --public-origin only rewrites generated pages; copied bytes stay exact.886++    let raw = read_bytes(&out.join("raw/raw/x.html"));887++    assert_eq!(888++        raw, b"<meta property=\"og:image\" content=\"/og.png\">",889++        "raw HTML bytes preserved"890++    );891++    let bad = read_bytes(&out.join("raw/raw/bad.html"));892++    assert_eq!(bad, vec![0xff, 0xfe, b'<', b'x', b'>']);893++    let artifact = read_bytes(&out.join(format!("releases/{VERSION}/artifact.html")));894++    assert_eq!(895++        artifact, b"<meta property=\"og:image\" content=\"/og.png\">",896++        "artifact bytes preserved"897++    );898++    assert!(read(&out.join("index.html")).contains("content=\"https://hu13.example/og.png\""));899++900++    let inbox = fresh("inbox");901++    let srv = start(&out, &inbox, &[]);902++    std::thread::sleep(Duration::from_millis(100));903++904++    let evil = get(905++        &srv.addr,906++        "/releases/v1%23x/evil%22%20onmouseover%3D%22alert%281%29.html",907++    );908++    assert!(evil.starts_with("HTTP/1.1 200"), "{evil}");909++    assert!(910++        evil.contains("Content-Type: application/octet-stream"),911++        "{evil}"912++    );913++    assert!(evil.contains("Content-Disposition: attachment"), "{evil}");914++915++    for path in [916++        "/releases//v1%23x/evil%22%20onmouseover%3D%22alert%281%29.html",917++        "/./releases/v1%23x/evil%22%20onmouseover%3D%22alert%281%29.html",918++    ] {919++        let bypass = get(&srv.addr, path);920++        assert!(bypass.starts_with("HTTP/1.1 200"), "{path}: {bypass}");921++        assert!(922++            bypass.contains("Content-Disposition: attachment"),923++            "{path}: {bypass}"924++        );925++    }926++927++    let page = get(&srv.addr, "/releases/v1%23x.html");928++    assert!(page.starts_with("HTTP/1.1 200"), "{page}");929++    assert!(page.contains("Content-Type: text/html"), "{page}");930++    assert!(!page.contains("Content-Disposition: attachment"), "{page}");931++932++    let og = get(&srv.addr, "/og.png");933++    assert!(og.contains("Content-Type: image/png"), "{og}");934++}935++936++#[test]937++fn review4_relative_repo_keeps_log_dots() {938++    let repo = fixture();939++    let out = fresh("rel-out");940++    let res = Command::new(env!("CARGO_BIN_EXE_hu13"))941++        .current_dir(&repo)942++        .arg("build")943++        .arg(".")944++        .arg("-o")945++        .arg(&out)946++        .output()947++        .expect("spawn build");948++    assert!(949++        res.status.success(),950++        "build failed: {}",951++        String::from_utf8_lossy(&res.stderr)952++    );953++    let ci = read(&out.join("ci/index.html"));954++    assert!(ci.contains("v0.1.0"), "version dots preserved: {ci}");955++    assert!(956++        !ci.contains("v0<repo>1<repo>0"),957++        "relative dot not redacted"958++    );959++    assert!(960++        ci.contains("&lt;repo&gt;") || ci.contains("<repo>"),961++        "absolute builder path still redacted"962++    );963++}964++965++#[test]966++fn review4_origin_and_static_symlink_gate() {967++    let inbox = fresh("origin-inbox");968++    let www = fresh("origin-www");969++    write(&www, "index.html", "<p>origin</p>\n");970++    let srv = start(&www, &inbox, &["--rate-per-hour", "100", "--quota", "100"]);971++    std::thread::sleep(Duration::from_millis(100));972++973++    let bad_port = post(&srv.addr, Some("http://127.0.0.1:1"));974++    assert!(bad_port.starts_with("HTTP/1.1 403"), "{bad_port}");975++    let bad_scheme = post(&srv.addr, Some(&format!("https://{}", srv.addr)));976++    assert!(bad_scheme.starts_with("HTTP/1.1 403"), "{bad_scheme}");977++    let good = post(&srv.addr, Some(&format!("http://{}", srv.addr)));978++    assert!(good.starts_with("HTTP/1.1 200"), "{good}");979++    assert_eq!(980++        std::fs::read_dir(&inbox).unwrap().count(),981++        1,982++        "only same-origin submission stored"983++    );984++985++    // A symlink inside www must not escape to an outside file.986++    let outside = fresh("outside");987++    write(&outside, "secret.txt", "SECRET\n");988++    #[cfg(unix)]989++    std::os::unix::fs::symlink(&outside, www.join("escape")).unwrap();990++    #[cfg(unix)]991++    {992++        let srv = start(&www, &inbox, &[]);993++        std::thread::sleep(Duration::from_millis(100));994++        let escaped = get(&srv.addr, "/escape/secret.txt");995++        assert!(escaped.starts_with("HTTP/1.1 404"), "{escaped}");996++        assert!(!escaped.contains("SECRET"));997++    }998++}999+-- 1000+2.56.01001+
.forge/prs/e5ae0b6d-a0f9-4857-9fd3-7f9af558fc9d/pr.md+13 −0
diff --git a/.forge/prs/e5ae0b6d-a0f9-4857-9fd3-7f9af558fc9d/pr.md b/.forge/prs/e5ae0b6d-a0f9-4857-9fd3-7f9af558fc9d/pr.mdnew file mode 100644index 0000000..76d6ada--- /dev/null+++ b/.forge/prs/e5ae0b6d-a0f9-4857-9fd3-7f9af558fc9d/pr.md@@ -0,0 +1,13 @@1+---2+id: e5ae0b6d-a0f9-4857-9fd3-7f9af558fc9d3+title: 'review #4: harden release artifacts, links, origin, CI redaction (#97-#105)'4+state: merged5+base: master6+head: fix/review4-hardening7+draft: false8+issues:9+- 69c0303b-0dcd-4b1b-a67c-04bcfdde011f10+ci: pass11+created: 2026-10-27T18:19:22.095189437Z12+updated: 2026-10-27T18:20:38.398508463Z13+---