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("<repo>") || 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+