#60 review #4: harden release artifacts, links, origin, CI redaction (#97-#105)
Patch
.forge/wiki/deploy+4 −3--- 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--- 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--- 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--- 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--- 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--- 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--- 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--- 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--- 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--- 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 −1--- /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("<repo>") || 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+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.