1 From 8e812dd2a9fe6d2a2e264a57d5a87452b9dba6cc 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