review #4: harden release artifacts, links, origin, CI redaction (#97-#105)

8e812ddgl0w

all commits

Message

review #4: harden release artifacts, links, origin, CI redaction (#97-#105)

Diff

.forge/wiki/deploy+4 −3
diff --git a/.forge/wiki/deploy b/.forge/wiki/deployindex 1fd24c2..ac9b9d3 100644--- a/.forge/wiki/deploy+++ b/.forge/wiki/deploy@@ -66,9 +66,10 @@ What happens inside (`entrypoint.sh`):6666   `/srv/www`) so the derived `repo.git` directory never looks tracked.6767 - Static hosting (Cloudflare Pages/nginx): publish the whole `dist/`6868   directory. `dist/repo.git` is plain files + `update-server-info`, and69-  `dist/_headers` carries the `/raw/**` download policy for Pages. On70-  nginx, add the equivalent `add_header Content-Disposition attachment`71-  for `/raw/` — committed HTML/SVG must never render from that path.69+  `dist/_headers` carries the `/raw/**` and artifact70+  `/releases/<version>/**` download policy for Pages. On nginx, add the71+  equivalent `add_header Content-Disposition attachment` for `/raw/` and72+  release artifact directories — committed HTML/SVG must never render.7273 - Cloudflare Pages inbox: the static `dist/` alone has **no inbox**. The7374   Pages Function in `functions/inbox/[kind].js` + R2 binding `INBOX` and7475   secret `INBOX_SALT` implement the same contract. See
docs/cloudflare.md+6 −4
diff --git a/docs/cloudflare.md b/docs/cloudflare.mdindex dedbd9d..b561c9e 100644--- a/docs/cloudflare.md+++ b/docs/cloudflare.md@@ -12,8 +12,8 @@ cargo build --release1212   --public-origin https://hu13.example.com1313 ```1414 15-`--public-origin` makes the clone command, `og:image`, Atom links and16-canonical URLs absolute. The build refuses output over Cloudflare's15+`--public-origin` makes the clone command, `og:image`, and Atom links16+absolute. The build refuses output over Cloudflare's1717 limits (20,000 files, 25 MiB/file) and writes `_headers` and1818 `_routes.json` into `dist/`.1919 @@ -91,5 +91,7 @@ it does not read R2. Copy objects down if you want the terminal reader.9191   `dist/` (drag-and-drop or a plain static host) gives a dead inbox.9292 - `repo.git` is a dumb-HTTP repo: cloning works on any static host, no9393   smart server. No GitHub dependency.94-- Committed `/raw/*` bytes always download (`Content-Disposition:95-  attachment`) on both `serve` and Pages.94+- Committed `/raw/*` bytes and release artifacts under95+  `/releases/<version>/*` always download (`Content-Disposition:96+  attachment`) on both `serve` and Pages, so hostile HTML/SVG can never97+  execute in the inbox origin.
functions/inbox/[kind].js+10 −4
diff --git a/functions/inbox/[kind].js b/functions/inbox/[kind].jsindex d7592b8..1fa9037 100644--- a/functions/inbox/[kind].js+++ b/functions/inbox/[kind].js@@ -83,20 +83,26 @@ function sleep(ms) {8383 }8484 8585 // Same-origin gate: browsers send Origin/Referer; agents (curl) send86-// neither and pass. `null`/unparsable/mismatched hosts fail closed.86+// neither and pass. `null`/unparsable/mismatched host or scheme fails87+// closed; `.host` keeps the port, so another local port is rejected.8788 function originAllowed(request) {88-  let host;89+  let target;8990   try {90-    host = new URL(request.url).host.toLowerCase();91+    target = new URL(request.url);9192   } catch {9293     return false;9394   }95+  const host = target.host.toLowerCase();96+  const protocol = target.protocol;9497   for (const name of ["origin", "referer"]) {9598     const value = request.headers.get(name);9699     if (value === null) continue;97100     if (!value || value.trim().toLowerCase() === "null") return false;98101     try {99-      if (new URL(value).host.toLowerCase() !== host) return false;102+      const claimed = new URL(value);103+      if (claimed.host.toLowerCase() !== host || claimed.protocol !== protocol) {104+        return false;105+      }100106     } catch {101107       return false;102108     }
src/build.rs+31 −11
diff --git a/src/build.rs b/src/build.rsindex 532802d..4274e88 100644--- a/src/build.rs+++ b/src/build.rs@@ -415,7 +415,7 @@ pub fn is_markdown(path: &str) -> bool {415415 /// browser truncates a link at `#`/`?` (f0rge #89). `/` stays literal416416 /// so file paths remain hierarchical; everything outside the unreserved417417 /// set becomes `%XX` of its UTF-8 bytes.418-fn encode_path(path: &str) -> String {418+pub(crate) fn encode_path(path: &str) -> String {419419     let mut out = String::with_capacity(path.len());420420     for &b in path.as_bytes() {421421         if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {@@ -427,7 +427,7 @@ fn encode_path(path: &str) -> String {427427     out428428 }429429 430-fn href_esc(path: &str) -> String {430+pub(crate) fn href_esc(path: &str) -> String {431431     esc(&encode_path(path))432432 }433433 @@ -1148,10 +1148,15 @@ pub const ROUTES_FILE: &str =11481148 /// inbox endpoints live. `serve` sets the same policy directly11491149 /// (f0rge #61); this file gives Pages the equivalent.11501150 pub const HEADERS_FILE: &str = "\1151-# generated by hu13: committed files are untrusted (f0rge #61)1151+# generated by hu13: committed files and release artifacts are untrusted11521152 /raw/*11531153   Content-Disposition: attachment11541154   X-Content-Type-Options: nosniff1155+# Artifacts are build outputs copied from .forge/releases/out/<ver>/;1156+# never let an artifact .html/.svg execute in the site origin (f0rge #97).1157+/releases/*/*1158+  Content-Disposition: attachment1159+  X-Content-Type-Options: nosniff11551160 /*11561161   X-Content-Type-Options: nosniff11571162   Referrer-Policy: strict-origin-when-cross-origin@@ -1324,14 +1329,29 @@ fn absolutize_social_meta(out: &Path, origin: &str) -> Result<(), String> {13241329             let path = ent.path();13251330             if path.is_dir() {13261331                 stack.push(path);1327-            } else if path.extension().is_some_and(|x| x == "html") {1328-                let html = std::fs::read_to_string(&path)1329-                    .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;1330-                let replaced = html.replace("content=\"/og.png\"", &absolute);1331-                if replaced != html {1332-                    std::fs::write(&path, replaced)1333-                        .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;1334-                }1332+                continue;1333+            }1334+            if !path.extension().is_some_and(|x| x == "html") {1335+                continue;1336+            }1337+            let rel = path.strip_prefix(out).unwrap_or(&path);1338+            let rel = rel.to_string_lossy().replace('\\', "/");1339+            // Only generated pages carry this marker. `/raw/` and release1340+            // artifacts are byte-for-byte copies of untrusted inputs: reading1341+            // them as UTF-8 can fail the build and rewriting them corrupts the1342+            // download (f0rge #100). `repo.git` is git internals, never HTML.1343+            if rel.starts_with("raw/")1344+                || rel.starts_with("repo.git/")1345+                || crate::releases::is_artifact_path(&rel)1346+            {1347+                continue;1348+            }1349+            let html = std::fs::read_to_string(&path)1350+                .map_err(|e| format!("hu13: social meta read {}: {e}", path.display()))?;1351+            let replaced = html.replace("content=\"/og.png\"", &absolute);1352+            if replaced != html {1353+                std::fs::write(&path, replaced)1354+                    .map_err(|e| format!("hu13: social meta write {}: {e}", path.display()))?;13351355             }13361356         }13371357     }
src/feed.rs+6 −7
diff --git a/src/feed.rs b/src/feed.rsindex 24be85d..f50a956 100644--- a/src/feed.rs+++ b/src/feed.rs@@ -7,6 +7,7 @@77 88 use std::path::Path;99 10+use crate::build::encode_path;1011 use crate::gitx;1112 use crate::releases;1213 @@ -89,17 +90,15 @@ pub fn read_release_entries(repo: &Path) -> Vec<FeedEntry> {8990             let summary = if first_line.is_empty() {9091                 format!("release {} ({})", r.version, r.created)9192             } else {92-                let mut s: String = first_line.chars().take(200).collect();93-                if s.len() > 200 {94-                    s.truncate(200);95-                }96-                s93+                // 200 Unicode scalar values, never a byte offset: a94+                // multibyte cut used to panic in `truncate` (f0rge #99).95+                first_line.chars().take(200).collect()9796             };9897             FeedEntry {9998                 title: r.display_title().to_string(),100-                id: format!("urn:release:{}", r.version),99+                id: format!("urn:release:{}", encode_path(&r.version)),101100                 updated: r.created.clone(),102-                link: format!("/releases/{}.html", r.version),101+                link: format!("/releases/{}.html", encode_path(&r.version)),103102                 summary,104103             }105104         })
src/forge.rs+6 −4
diff --git a/src/forge.rs b/src/forge.rsindex 56b90f9..9da9bd1 100644--- a/src/forge.rs+++ b/src/forge.rs@@ -770,10 +770,12 @@ fn load_milestones(root: &Path) -> Vec<Milestone> {770770 /// the canonical repo root with `<repo>`, then `$HOME` with `~`.771771 fn redact_paths(text: &str, root: &Path) -> String {772772     let mut out = text.to_string();773-    let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());774-    for p in [root_abs.as_path(), root] {775-        let needle = p.to_string_lossy();776-        if !needle.is_empty() && needle != "/" {773+    // Only replace a canonical absolute path. `root` may be `.forge` under a774+    // relative repo argument (`.`); using the raw relative form as a needle775+    // would replace every dot in `v0.1.0`/`00.log` (f0rge #103).776+    if let Ok(root_abs) = root.canonicalize() {777+        let needle = root_abs.to_string_lossy();778+        if !needle.is_empty() && needle != "/" && needle != "." && needle != ".." {777779             out = out.replace(needle.as_ref(), "<repo>");778780         }779781     }
src/releases.rs+30 −3
diff --git a/src/releases.rs b/src/releases.rsindex 4ef2d1b..75acf0c 100644--- a/src/releases.rs+++ b/src/releases.rs@@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};2121 2222 use serde::Deserialize;2323 24-use crate::build::markdown_to_html;24+use crate::build::{href_esc, markdown_to_html};2525 use crate::layout::{esc, page_site_with_provenance};2626 2727 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).@@ -326,8 +326,21 @@ pub fn sha256_lines(body: &str) -> Vec<String> {326326         .collect()327327 }328328 329+/// True for `releases/<version>/...` artifact paths (but not the generated330+/// `releases/<version>.html` page). Both the server and the Pages header331+/// policy use this to keep artifacts downloads, never active documents.332+#[must_use]333+pub fn is_artifact_path(path: &str) -> bool {334+    let mut parts = path.split('/');335+    matches!(parts.next(), Some("releases"))336+        && matches!(parts.next(), Some(version) if !version.is_empty())337+        && parts.next().is_some()338+}339+329340 fn artifact_href(version: &str, path: &str) -> String {330-    format!("/releases/{}/{path}", esc(version))341+    // Version and artifact path are repo-controlled: percent-encode both342+    // (`#`/`?`/quotes) before HTML-escaping the attribute (f0rge #98/#104).343+    href_esc(&format!("/releases/{version}/{path}"))331344 }332345 333346 fn render_release_page(@@ -422,8 +435,9 @@ pub fn render_loaded(422435     } else {423436         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");424437         for rel in releases {438+            let href = href_esc(&format!("/releases/{}.html", rel.version));425439             body.push_str(&format!(426-                "<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",440+                "<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",427441                 v = esc(&rel.version),428442                 ve = esc(&rel.version),429443                 t = esc(rel.display_title()),@@ -490,6 +504,19 @@ mod tests {490504         assert!(sha256_lines("no hashes here\n").is_empty());491505     }492506 507+    #[test]508+    fn artifact_paths_and_links_are_classified_and_encoded() {509+        assert!(is_artifact_path("releases/v1.0/app.bin"));510+        assert!(is_artifact_path("releases/v1.0/nested/app.bin"));511+        assert!(!is_artifact_path("releases/v1.0.html"));512+        assert!(!is_artifact_path("releases/index.html"));513+        assert!(!is_artifact_path("raw/releases/v1/app.bin"));514+515+        let href = artifact_href("v1#x", "a\" b#c?.txt");516+        assert!(href.starts_with("/releases/v1%23x/a%22%20b%23c%3F.txt"));517+        assert!(!href.contains('"'), "quotes are encoded");518+    }519+493520     #[test]494521     fn display_title_falls_back_to_version() {495522         let r = Release {
src/serve.rs+172 −34
diff --git a/src/serve.rs b/src/serve.rsindex 85df371..9edb446 100644--- a/src/serve.rs+++ b/src/serve.rs@@ -170,11 +170,36 @@ fn absolutize(p: &Path) -> PathBuf {170170     }171171 }172172 173+/// Resolve symlinks in the deepest existing ancestor of `p`, then append174+/// the non-existent suffix. Startup may run before the inbox dir exists.175+fn resolve_existing(p: &Path) -> PathBuf {176+    let abs = absolutize(p);177+    let mut suffix: Vec<std::ffi::OsString> = Vec::new();178+    let mut cur = abs.as_path();179+    loop {180+        if let Ok(mut resolved) = std::fs::canonicalize(cur) {181+            for name in suffix.iter().rev() {182+                resolved.push(name);183+            }184+            return resolved;185+        }186+        let Some(name) = cur.file_name() else {187+            return abs;188+        };189+        suffix.push(name.to_os_string());190+        let Some(parent) = cur.parent() else {191+            return abs;192+        };193+        cur = parent;194+    }195+}196+173197 /// Refuse to start when the inbox dir is inside (or equal to) www.174-/// Inbox files must never be reachable over HTTP.198+/// Inbox files must never be reachable over HTTP. Resolve symlinks: a199+/// lexical-only check lets `--inbox-dir link-to-www/private` through.175200 pub fn ensure_inbox_outside_www(www: &Path, inbox_dir: &Path) -> Result<(), String> {176-    let www = absolutize(www);177-    let inbox = absolutize(inbox_dir);201+    let www = resolve_existing(www);202+    let inbox = resolve_existing(inbox_dir);178203     if inbox == www || inbox.starts_with(&www) {179204         return Err(format!(180205             "hu13: refusing to start: inbox-dir {} is inside www {} (inbox must never be served)",@@ -216,6 +241,12 @@ pub fn run(opts: &ServeOptions) -> i32 {216241             return 1;217242         }218243     }244+    // Re-check after creation: a dangling symlink may only reveal that its245+    // target is inside www once `create_dir_all` materializes it.246+    if let Err(e) = ensure_inbox_outside_www(&opts.www, &opts.inbox_dir) {247+        eprintln!("{e}");248+        return 1;249+    }219250     let listener = match TcpListener::bind(&opts.listen) {220251         Ok(l) => l,221252         Err(e) => {@@ -451,6 +482,16 @@ fn content_type_for(name: &str) -> &'static str {451482         "text/css; charset=utf-8"452483     } else if lower.ends_with(".svg") {453484         "image/svg+xml"485+    } else if lower.ends_with(".png") {486+        "image/png"487+    } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {488+        "image/jpeg"489+    } else if lower.ends_with(".gif") {490+        "image/gif"491+    } else if lower.ends_with(".webp") {492+        "image/webp"493+    } else if lower.ends_with(".ico") {494+        "image/x-icon"454495     } else if lower.ends_with(".webmanifest") {455496         "application/manifest+json"456497     } else if lower.ends_with(".json") {@@ -593,48 +634,77 @@ fn decode_path(s: &str) -> Option<String> {593634     String::from_utf8(bytes).ok()594635 }595636 596-/// Hostname of an `Origin`/`Referer` value (`scheme://host[:port][/...]`)597-/// or of a `Host` header (`host[:port]`), lowercased, brackets stripped.598-/// `None` = unparsable (fail closed when it came from the browser).599-fn host_of_url(value: &str) -> Option<String> {637+/// Normalized authority (`host` + effective port) of an `Origin`/`Referer`638+/// URL (`scheme://host[:port][/...]`) or a `Host` header (`host[:port]`).639+/// Scheme defaults matter: `http` → 80, `https` → 443, and a bare `Host`640+/// on this plain-HTTP server → 80. `None` = unparsable/unsupported → fail641+/// closed when supplied by the browser.642+#[derive(Debug, PartialEq, Eq)]643+struct Authority {644+    scheme: String,645+    host: String,646+    port: u16,647+}648+649+fn authority_of_url(value: &str, default_scheme: &str) -> Option<Authority> {600650     let v = value.trim();601-    if (v.is_empty()) || v.eq_ignore_ascii_case("null") {651+    if v.is_empty() || v.eq_ignore_ascii_case("null") {602652         return None;603653     }604-    let after_scheme = match v.split_once("://") {605-        Some((_, rest)) => rest,606-        None => v,654+    let (scheme, rest) = match v.split_once("://") {655+        Some((scheme, rest)) => {656+            let scheme = scheme.to_ascii_lowercase();657+            if scheme != "http" && scheme != "https" {658+                return None;659+            }660+            (scheme, rest)661+        }662+        // A bare `Host` has no scheme; use the request scheme (plain HTTP,663+        // or `X-Forwarded-Proto` when behind a TLS proxy).664+        None => (default_scheme.to_string(), v),607665     };608-    let hostport = after_scheme.split('/').next().unwrap_or("");666+    let hostport = rest.split('/').next().unwrap_or("");609667     let hostport = hostport.split('@').next_back().unwrap_or("");610668     if hostport.is_empty() {611669         return None;612670     }613-    // Split port (last `:` with only digits after, or `[v6]:port`).614-    let host = if let Some(stripped) = hostport.strip_prefix('[') {615-        match stripped.split_once(']') {616-            Some((h, _)) => h,617-            None => return None,618-        }671+    let (host, port) = if let Some(stripped) = hostport.strip_prefix('[') {672+        let (host, after) = stripped.split_once(']')?;673+        let port = if after.is_empty() {674+            None675+        } else {676+            Some(after.strip_prefix(':')?)677+        };678+        (host, port)619679     } else if hostport.matches(':').count() == 1 {620-        match hostport.split_once(':') {621-            Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,622-            Some(_) => return None,623-            None => hostport,680+        let (host, port) = hostport.split_once(':')?;681+        if port.is_empty() {682+            return None;624683         }684+        (host, Some(port))625685     } else {626-        hostport686+        (hostport, None)627687     };628688     let host = host.trim().trim_matches('.').to_ascii_lowercase();629689     if host.is_empty() {630690         return None;631691     }632-    Some(host)692+    let port = match port {693+        Some(port) => port.parse::<u16>().ok()?,694+        None => match scheme.as_str() {695+            "https" => 443,696+            "http" => 80,697+            _ => return None,698+        },699+    };700+    Some(Authority { scheme, host, port })633701 }634702 635703 /// Same-origin gate for the two POST routes: no `Origin` and no636704 /// `Referer` (curl/agents) → allow; browser-supplied `Origin` (else637-/// `Referer`) must match the `Host` hostname → else `403`. Never echoes.705+/// `Referer`) must match the `Host` authority, including port → else706+/// `403`. Comparing only hostnames lets another local port forge a707+/// same-origin request (f0rge #101). Never echoes.638708 #[must_use]639709 fn origin_allowed(head: &Head) -> bool {640710     let origin = header(head, "origin");@@ -649,9 +719,15 @@ fn origin_allowed(head: &Head) -> bool {649719     let Some(claimed) = claimed else {650720         return true; // curl sends none651721     };722+    let scheme = header(head, "x-forwarded-proto")723+        .and_then(|v| v.split(',').next())724+        .map(str::trim)725+        .map(str::to_ascii_lowercase)726+        .filter(|v| v == "http" || v == "https")727+        .unwrap_or_else(|| "http".to_string());652728     let (Some(want), Some(got)) = (653-        header(head, "host").and_then(host_of_url),654-        host_of_url(claimed),729+        header(head, "host").and_then(|h| authority_of_url(h, &scheme)),730+        authority_of_url(claimed, &scheme),655731     ) else {656732         return false; // unparsable Host or Origin → fail closed657733     };@@ -706,8 +782,9 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {706782     } else {707783         rel.to_string()708784     };709-    let dest = absolutize(&shared.www.join(&file_rel));710-    if dest != absolutize(&shared.www) && !dest.starts_with(absolutize(&shared.www)) {785+    let www = resolve_existing(&shared.www);786+    let dest = resolve_existing(&shared.www.join(&file_rel));787+    if dest != www && !dest.starts_with(&www) {711788         static_not_found(&mut stream, &head, shared);712789         return;713790     }@@ -726,13 +803,21 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {726803         static_error(&mut stream, 405);727804         return;728805     }729-    let is_raw = file_rel.starts_with("raw/");730-    let (ctype, disposition) = if is_raw {731-        raw_content_type(&file_rel)806+    // Classify by the resolved path, not the raw request string: a request807+    // like `/releases//v1/evil.html` or `/./releases/...` maps to the same808+    // file but would bypass a string-prefix check (f0rge #97).809+    let served_rel = dest810+        .strip_prefix(&www)811+        .map(|p| p.to_string_lossy().replace('\\', "/"))812+        .unwrap_or_else(|_| file_rel.clone());813+    let is_raw = served_rel.starts_with("raw/");814+    let is_artifact = crate::releases::is_artifact_path(&served_rel);815+    let (ctype, disposition) = if is_raw || is_artifact {816+        raw_content_type(&served_rel)732817     } else {733-        (content_type_for(&file_rel), "inline")818+        (content_type_for(&served_rel), "inline")734819     };735-    let extra: &[(&str, &str)] = if is_raw {820+    let extra: &[(&str, &str)] = if is_raw || is_artifact {736821         &[("Content-Disposition", disposition)]737822     } else {738823         &[]@@ -1195,6 +1280,9 @@ mod tests {11951280         assert!(content_type_for("f.atom").contains("atom"));11961281         // m6 favicon (f0rge issue #16): svg icon + web app manifest.11971282         assert_eq!(content_type_for("favicon.svg"), "image/svg+xml");1283+        assert_eq!(content_type_for("og.png"), "image/png");1284+        assert_eq!(content_type_for("photo.JPG"), "image/jpeg");1285+        assert_eq!(content_type_for("icon.ico"), "image/x-icon");11981286         assert_eq!(11991287             content_type_for("manifest.webmanifest"),12001288             "application/manifest+json"@@ -1202,6 +1290,35 @@ mod tests {12021290         assert_eq!(content_type_for("b.bundle"), "application/octet-stream");12031291     }12041292 1293+    #[cfg(unix)]1294+    #[test]1295+    fn inbox_symlink_into_www_refused() {1296+        use std::os::unix::fs::symlink;1297+        let base = std::env::temp_dir().join(format!(1298+            "hu13-inbox-symlink-{}-{}",1299+            std::process::id(),1300+            std::time::SystemTime::now()1301+                .duration_since(std::time::UNIX_EPOCH)1302+                .map(|d| d.as_nanos())1303+                .unwrap_or(0)1304+        ));1305+        let www = base.join("www");1306+        let private = www.join("private");1307+        std::fs::create_dir_all(&private).unwrap();1308+        let inbox = base.join("inbox-link");1309+        symlink(&private, &inbox).unwrap();1310+        assert!(ensure_inbox_outside_www(&www, &inbox).is_err());1311+1312+        let parent = base.join("linked-parent");1313+        symlink(&www, &parent).unwrap();1314+        assert!(ensure_inbox_outside_www(&www, &parent.join("inbox")).is_err());1315+1316+        let www_link = base.join("www-link");1317+        symlink(&www, &www_link).unwrap();1318+        assert!(ensure_inbox_outside_www(&www_link, &www.join("inbox")).is_err());1319+        std::fs::remove_dir_all(&base).ok();1320+    }1321+12051322     #[test]12061323     fn bad_paths_rejected() {12071324         assert!(decode_path("/%2e%2e/x").is_some()); // decodes; `..` blocked later@@ -1238,6 +1355,27 @@ mod tests {12381355             Some("https://evil.example"),12391356             None12401357         )));1358+        // Different port or scheme is a different origin (f0rge #101).1359+        assert!(!origin_allowed(&head_with(1360+            "127.0.0.1:39313",1361+            Some("http://127.0.0.1:9999"),1362+            None1363+        )));1364+        assert!(!origin_allowed(&head_with(1365+            "127.0.0.1:39313",1366+            Some("https://127.0.0.1:39313"),1367+            None1368+        )));1369+        assert!(!origin_allowed(&head_with(1370+            "a.example:443",1371+            Some("https://a.example"),1372+            None1373+        )));1374+        let mut proxied = head_with("a.example", Some("https://a.example"), None);1375+        proxied1376+            .headers1377+            .push(("x-forwarded-proto".to_string(), "https".to_string()));1378+        assert!(origin_allowed(&proxied), "TLS proxy can declare https");12411379         assert!(!origin_allowed(&head_with(12421380             "127.0.0.1:1",12431381             Some("null"),
src/wiki.rs+3 −3
diff --git a/src/wiki.rs b/src/wiki.rsindex a0e8d7e..25bfc48 100644--- a/src/wiki.rs+++ b/src/wiki.rs@@ -14,7 +14,7 @@1414 use std::collections::BTreeMap;1515 use std::path::Path;1616 17-use crate::build::markdown_to_html;17+use crate::build::{href_esc, markdown_to_html};1818 use crate::layout::{esc, page_site_with_provenance};1919 2020 /// Wiki files larger than this are skipped (schema SEC-01: 256 KiB).@@ -288,9 +288,9 @@ pub fn render(288288             } else {289289                 String::new()290290             };291+            let href = href_esc(&format!("/wiki/{}.html", p.path));291292             body.push_str(&format!(292-                "<li><a href=\"/wiki/{}.html\">{}</a>{hint}</li>\n",293-                esc(&p.path),293+                "<li><a href=\"{href}\">{}</a>{hint}</li>\n",294294                 esc(&p.title),295295             ));296296         }
tests/pages_inbox.test.mjs+8 −0
diff --git a/tests/pages_inbox.test.mjs b/tests/pages_inbox.test.mjsindex f9625c8..a825c71 100644--- a/tests/pages_inbox.test.mjs+++ b/tests/pages_inbox.test.mjs@@ -128,6 +128,14 @@ test("cross-origin browser POST is rejected; curl (no Origin) passes", async ()128128   assert.equal(bad.res.status, 403);129129   const nullOrigin = await post("issue", validIssue, { origin: "null" });130130   assert.equal(nullOrigin.res.status, 403);131+  const badPort = await post("issue", validIssue, {132+    origin: "https://hu13.test:444",133+  });134+  assert.equal(badPort.res.status, 403);135+  const badScheme = await post("issue", validIssue, {136+    origin: "http://hu13.test",137+  });138+  assert.equal(badScheme.res.status, 403);131139   const same = await post("issue", validIssue, { origin: "https://hu13.test" });132140   assert.equal(same.res.status, 200);133141 });
tests/review4_hardening.rs+344 −0
diff --git a/tests/review4_hardening.rs b/tests/review4_hardening.rsnew file mode 100644index 0000000..961f0bc--- /dev/null+++ b/tests/review4_hardening.rs@@ -0,0 +1,344 @@1+//! Review #4 hardening (f0rge #97–#105): release artifacts are downloads,2+//! artifact/feed/wiki URLs are encoded, social-meta postprocessing skips3+//! copied bytes, origin checks include port/scheme, and relative CI logs4+//! keep their dots.5+6+use std::io::{BufRead, BufReader, Read, Write};7+use std::net::TcpStream;8+use std::path::{Path, PathBuf};9+use std::process::{Child, Command, Stdio};10+use std::sync::atomic::{AtomicU64, Ordering};11+use std::time::Duration;12+13+static SEQ: AtomicU64 = AtomicU64::new(0);14+15+fn fresh(prefix: &str) -> PathBuf {16+    let dir = std::env::temp_dir().join(format!(17+        "hu13-review4-{prefix}-{}-{}",18+        std::process::id(),19+        std::time::SystemTime::now()20+            .duration_since(std::time::UNIX_EPOCH)21+            .map(|d| d.as_nanos())22+            .unwrap_or(0)23+            .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))24+    ));25+    std::fs::create_dir_all(&dir).unwrap();26+    dir27+}28+29+fn git(repo: &Path, args: &[&str]) {30+    let st = Command::new("git")31+        .arg("-C")32+        .arg(repo)33+        .args(args)34+        .status()35+        .expect("spawn git");36+    assert!(st.success(), "git {args:?} failed");37+}38+39+fn write(repo: &Path, rel: &str, body: &str) {40+    let dest = repo.join(rel);41+    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();42+    std::fs::write(dest, body).unwrap();43+}44+45+fn write_bytes(repo: &Path, rel: &str, body: &[u8]) {46+    let dest = repo.join(rel);47+    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();48+    std::fs::write(dest, body).unwrap();49+}50+51+fn read_bytes(path: &Path) -> Vec<u8> {52+    std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))53+}54+55+fn read(path: &Path) -> String {56+    String::from_utf8(read_bytes(path)).expect("utf8")57+}58+59+const VERSION: &str = "v1#x";60+const ARTIFACT_EVIL: &str = "evil\" onmouseover=\"alert(1).html";61+const ARTIFACT_HASH: &str = "hash#q?%.txt";62+63+fn fixture() -> PathBuf {64+    let repo = fresh("repo");65+    git(&repo, &["init", "-b", "main"]);66+    git(&repo, &["config", "user.name", "hu13-test"]);67+    git(&repo, &["config", "user.email", "[email protected]"]);68+    git(&repo, &["config", "commit.gpgsign", "false"]);69+    write(&repo, "README.md", "# review4 fixture\n");70+    write(71+        &repo,72+        "raw/x.html",73+        "<meta property=\"og:image\" content=\"/og.png\">",74+    );75+    write_bytes(&repo, "raw/bad.html", &[0xff, 0xfe, b'<', b'x', b'>']);76+    write(77+        &repo,78+        ".forge/releases/v1#x.md",79+        &format!(80+            "---\nversion: \"{VERSION}\"\ntitle: Frag\ncreated: 2026-09-10T02:00:00Z\n---\n{}\nnotes\n",81+            "€".repeat(200)82+        ),83+    );84+    let artifact_dir = format!(".forge/releases/out/{VERSION}");85+    write(86+        &repo,87+        &format!("{artifact_dir}/{ARTIFACT_EVIL}"),88+        "<script>alert(1)</script>",89+    );90+    write(&repo, &format!("{artifact_dir}/{ARTIFACT_HASH}"), "hash\n");91+    write(92+        &repo,93+        &format!("{artifact_dir}/artifact.html"),94+        "<meta property=\"og:image\" content=\"/og.png\">",95+    );96+    write(&repo, ".forge/wiki/a#b.md", "# Hash wiki\n\nbody\n");97+    write(98+        &repo,99+        ".forge/ci/runs/2026-09-10T00-00-00Z/summary.md",100+        "---\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",101+    );102+    write(103+        &repo,104+        ".forge/ci/runs/2026-09-10T00-00-00Z/steps/00_test.sh.log",105+        &format!("Checking hu13 v0.1.0 at {}\n", repo.display()),106+    );107+    git(&repo, &["add", "-A"]);108+    git(&repo, &["commit", "-m", "review4 fixture"]);109+    repo110+}111+112+fn build(repo: &Path, out: &Path) -> std::process::Output {113+    Command::new(env!("CARGO_BIN_EXE_hu13"))114+        .arg("build")115+        .arg(repo)116+        .arg("-o")117+        .arg(out)118+        .arg("--public-origin")119+        .arg("https://hu13.example")120+        .output()121+        .expect("spawn hu13 build")122+}123+124+struct Server {125+    child: Child,126+    addr: String,127+}128+129+impl Drop for Server {130+    fn drop(&mut self) {131+        self.child.kill().ok();132+        self.child.wait().ok();133+    }134+}135+136+fn start(www: &Path, inbox: &Path, extra: &[&str]) -> Server {137+    let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));138+    cmd.arg("serve")139+        .arg("--www")140+        .arg(www)141+        .arg("--inbox-dir")142+        .arg(inbox)143+        .arg("--listen")144+        .arg("127.0.0.1:0")145+        .args(extra)146+        .stdout(Stdio::piped())147+        .stderr(Stdio::null());148+    let mut child = cmd.spawn().expect("spawn serve");149+    let stdout = child.stdout.take().expect("stdout");150+    let mut addr = String::new();151+    for line in BufReader::new(stdout).lines().take(50).flatten() {152+        if let Some(rest) = line.split("listening on ").nth(1) {153+            addr = rest.trim().to_string();154+            break;155+        }156+    }157+    assert!(!addr.is_empty(), "serve printed no listening line");158+    Server { child, addr }159+}160+161+fn get(addr: &str, path: &str) -> String {162+    let mut s = TcpStream::connect(addr).expect("connect");163+    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();164+    write!(165+        s,166+        "GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"167+    )168+    .unwrap();169+    let mut out = Vec::new();170+    s.read_to_end(&mut out).unwrap();171+    String::from_utf8_lossy(&out).into_owned()172+}173+174+fn post(addr: &str, origin: Option<&str>) -> String {175+    let body = "title=Hello&body=World&license=agree";176+    let origin_line = origin177+        .map(|o| format!("Origin: {o}\r\n"))178+        .unwrap_or_default();179+    let req = format!(180+        "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}",181+        body.len()182+    );183+    let mut s = TcpStream::connect(addr).expect("connect");184+    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();185+    s.write_all(req.as_bytes()).unwrap();186+    let mut out = Vec::new();187+    s.read_to_end(&mut out).unwrap();188+    String::from_utf8_lossy(&out).into_owned()189+}190+191+#[test]192+fn review4_build_and_release_hardening() {193+    let repo = fixture();194+    let out = fresh("out");195+    let res = build(&repo, &out);196+    assert!(197+        res.status.success(),198+        "build failed: {}",199+        String::from_utf8_lossy(&res.stderr)200+    );201+202+    let release = read(&out.join(format!("releases/{VERSION}.html")));203+    assert!(204+        release.contains("href=\"/releases/v1%23x/evil%22%20onmouseover"),205+        "{release}"206+    );207+    assert!(!release.contains("onmouseover=\"alert(1)\""));208+    assert!(209+        release.contains("hash%23q%3F%25.txt"),210+        "artifact path encoded"211+    );212+213+    let index = read(&out.join("releases/index.html"));214+    assert!(index.contains("href=\"/releases/v1%23x.html\""), "{index}");215+    let wiki = read(&out.join("wiki/index.html"));216+    assert!(wiki.contains("href=\"/wiki/a%23b.md.html\""), "{wiki}");217+    let feed = read(&out.join("feed.atom"));218+    assert!(feed.contains("/releases/v1%23x.html"), "{feed}");219+    assert!(220+        feed.contains(&"€".repeat(200)),221+        "multibyte summary truncation must not panic or corrupt text"222+    );223+224+    let headers = read(&out.join("_headers"));225+    assert!(headers.contains("/releases/*/*"), "{headers}");226+    assert!(227+        headers.contains("Content-Disposition: attachment"),228+        "{headers}"229+    );230+231+    // --public-origin only rewrites generated pages; copied bytes stay exact.232+    let raw = read_bytes(&out.join("raw/raw/x.html"));233+    assert_eq!(234+        raw, b"<meta property=\"og:image\" content=\"/og.png\">",235+        "raw HTML bytes preserved"236+    );237+    let bad = read_bytes(&out.join("raw/raw/bad.html"));238+    assert_eq!(bad, vec![0xff, 0xfe, b'<', b'x', b'>']);239+    let artifact = read_bytes(&out.join(format!("releases/{VERSION}/artifact.html")));240+    assert_eq!(241+        artifact, b"<meta property=\"og:image\" content=\"/og.png\">",242+        "artifact bytes preserved"243+    );244+    assert!(read(&out.join("index.html")).contains("content=\"https://hu13.example/og.png\""));245+246+    let inbox = fresh("inbox");247+    let srv = start(&out, &inbox, &[]);248+    std::thread::sleep(Duration::from_millis(100));249+250+    let evil = get(251+        &srv.addr,252+        "/releases/v1%23x/evil%22%20onmouseover%3D%22alert%281%29.html",253+    );254+    assert!(evil.starts_with("HTTP/1.1 200"), "{evil}");255+    assert!(256+        evil.contains("Content-Type: application/octet-stream"),257+        "{evil}"258+    );259+    assert!(evil.contains("Content-Disposition: attachment"), "{evil}");260+261+    for path in [262+        "/releases//v1%23x/evil%22%20onmouseover%3D%22alert%281%29.html",263+        "/./releases/v1%23x/evil%22%20onmouseover%3D%22alert%281%29.html",264+    ] {265+        let bypass = get(&srv.addr, path);266+        assert!(bypass.starts_with("HTTP/1.1 200"), "{path}: {bypass}");267+        assert!(268+            bypass.contains("Content-Disposition: attachment"),269+            "{path}: {bypass}"270+        );271+    }272+273+    let page = get(&srv.addr, "/releases/v1%23x.html");274+    assert!(page.starts_with("HTTP/1.1 200"), "{page}");275+    assert!(page.contains("Content-Type: text/html"), "{page}");276+    assert!(!page.contains("Content-Disposition: attachment"), "{page}");277+278+    let og = get(&srv.addr, "/og.png");279+    assert!(og.contains("Content-Type: image/png"), "{og}");280+}281+282+#[test]283+fn review4_relative_repo_keeps_log_dots() {284+    let repo = fixture();285+    let out = fresh("rel-out");286+    let res = Command::new(env!("CARGO_BIN_EXE_hu13"))287+        .current_dir(&repo)288+        .arg("build")289+        .arg(".")290+        .arg("-o")291+        .arg(&out)292+        .output()293+        .expect("spawn build");294+    assert!(295+        res.status.success(),296+        "build failed: {}",297+        String::from_utf8_lossy(&res.stderr)298+    );299+    let ci = read(&out.join("ci/index.html"));300+    assert!(ci.contains("v0.1.0"), "version dots preserved: {ci}");301+    assert!(302+        !ci.contains("v0<repo>1<repo>0"),303+        "relative dot not redacted"304+    );305+    assert!(306+        ci.contains("&lt;repo&gt;") || ci.contains("<repo>"),307+        "absolute builder path still redacted"308+    );309+}310+311+#[test]312+fn review4_origin_and_static_symlink_gate() {313+    let inbox = fresh("origin-inbox");314+    let www = fresh("origin-www");315+    write(&www, "index.html", "<p>origin</p>\n");316+    let srv = start(&www, &inbox, &["--rate-per-hour", "100", "--quota", "100"]);317+    std::thread::sleep(Duration::from_millis(100));318+319+    let bad_port = post(&srv.addr, Some("http://127.0.0.1:1"));320+    assert!(bad_port.starts_with("HTTP/1.1 403"), "{bad_port}");321+    let bad_scheme = post(&srv.addr, Some(&format!("https://{}", srv.addr)));322+    assert!(bad_scheme.starts_with("HTTP/1.1 403"), "{bad_scheme}");323+    let good = post(&srv.addr, Some(&format!("http://{}", srv.addr)));324+    assert!(good.starts_with("HTTP/1.1 200"), "{good}");325+    assert_eq!(326+        std::fs::read_dir(&inbox).unwrap().count(),327+        1,328+        "only same-origin submission stored"329+    );330+331+    // A symlink inside www must not escape to an outside file.332+    let outside = fresh("outside");333+    write(&outside, "secret.txt", "SECRET\n");334+    #[cfg(unix)]335+    std::os::unix::fs::symlink(&outside, www.join("escape")).unwrap();336+    #[cfg(unix)]337+    {338+        let srv = start(&www, &inbox, &[]);339+        std::thread::sleep(Duration::from_millis(100));340+        let escaped = get(&srv.addr, "/escape/secret.txt");341+        assert!(escaped.starts_with("HTTP/1.1 404"), "{escaped}");342+        assert!(!escaped.contains("SECRET"));343+    }344+}