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/:version/*"), "{headers}");226 assert!(227 !headers.contains("/releases/*/*"),228 "two splats are invalid on Pages: {headers}"229 );230 assert!(231 headers.contains("Content-Disposition: attachment"),232 "{headers}"233 );234 235 // --public-origin only rewrites generated pages; copied bytes stay exact.236 let raw = read_bytes(&out.join("raw/raw/x.html"));237 assert_eq!(238 raw, b"<meta property=\"og:image\" content=\"/og.png\">",239 "raw HTML bytes preserved"240 );241 let bad = read_bytes(&out.join("raw/raw/bad.html"));242 assert_eq!(bad, vec![0xff, 0xfe, b'<', b'x', b'>']);243 let artifact = read_bytes(&out.join(format!("releases/{VERSION}/artifact.html")));244 assert_eq!(245 artifact, b"<meta property=\"og:image\" content=\"/og.png\">",246 "artifact bytes preserved"247 );248 assert!(read(&out.join("index.html")).contains("content=\"https://hu13.example/og.png\""));249 250 let inbox = fresh("inbox");251 let srv = start(&out, &inbox, &[]);252 std::thread::sleep(Duration::from_millis(100));253 254 let evil = get(255 &srv.addr,256 "/releases/v1%23x/evil%22%20onmouseover%3D%22alert%281%29.html",257 );258 assert!(evil.starts_with("HTTP/1.1 200"), "{evil}");259 assert!(260 evil.contains("Content-Type: application/octet-stream"),261 "{evil}"262 );263 assert!(evil.contains("Content-Disposition: attachment"), "{evil}");264 265 for path in [266 "/releases//v1%23x/evil%22%20onmouseover%3D%22alert%281%29.html",267 "/./releases/v1%23x/evil%22%20onmouseover%3D%22alert%281%29.html",268 ] {269 let bypass = get(&srv.addr, path);270 assert!(bypass.starts_with("HTTP/1.1 200"), "{path}: {bypass}");271 assert!(272 bypass.contains("Content-Disposition: attachment"),273 "{path}: {bypass}"274 );275 }276 277 let page = get(&srv.addr, "/releases/v1%23x.html");278 assert!(page.starts_with("HTTP/1.1 200"), "{page}");279 assert!(page.contains("Content-Type: text/html"), "{page}");280 assert!(!page.contains("Content-Disposition: attachment"), "{page}");281 282 let og = get(&srv.addr, "/og.png");283 assert!(og.contains("Content-Type: image/png"), "{og}");284 }285 286 #[test]287 fn review4_relative_repo_keeps_log_dots() {288 let repo = fixture();289 let out = fresh("rel-out");290 let res = Command::new(env!("CARGO_BIN_EXE_hu13"))291 .current_dir(&repo)292 .arg("build")293 .arg(".")294 .arg("-o")295 .arg(&out)296 .output()297 .expect("spawn build");298 assert!(299 res.status.success(),300 "build failed: {}",301 String::from_utf8_lossy(&res.stderr)302 );303 let ci = read(&out.join("ci/index.html"));304 assert!(ci.contains("v0.1.0"), "version dots preserved: {ci}");305 assert!(306 !ci.contains("v0<repo>1<repo>0"),307 "relative dot not redacted"308 );309 assert!(310 ci.contains("<repo>") || ci.contains("<repo>"),311 "absolute builder path still redacted"312 );313 }314 315 #[test]316 fn review4_origin_and_static_symlink_gate() {317 let inbox = fresh("origin-inbox");318 let www = fresh("origin-www");319 write(&www, "index.html", "<p>origin</p>\n");320 let srv = start(&www, &inbox, &["--rate-per-hour", "100", "--quota", "100"]);321 std::thread::sleep(Duration::from_millis(100));322 323 let bad_port = post(&srv.addr, Some("http://127.0.0.1:1"));324 assert!(bad_port.starts_with("HTTP/1.1 403"), "{bad_port}");325 let bad_scheme = post(&srv.addr, Some(&format!("https://{}", srv.addr)));326 assert!(bad_scheme.starts_with("HTTP/1.1 403"), "{bad_scheme}");327 let good = post(&srv.addr, Some(&format!("http://{}", srv.addr)));328 assert!(good.starts_with("HTTP/1.1 200"), "{good}");329 assert_eq!(330 std::fs::read_dir(&inbox).unwrap().count(),331 1,332 "only same-origin submission stored"333 );334 335 // A symlink inside www must not escape to an outside file.336 let outside = fresh("outside");337 write(&outside, "secret.txt", "SECRET\n");338 #[cfg(unix)]339 std::os::unix::fs::symlink(&outside, www.join("escape")).unwrap();340 #[cfg(unix)]341 {342 let srv = start(&www, &inbox, &[]);343 std::thread::sleep(Duration::from_millis(100));344 let escaped = get(&srv.addr, "/escape/secret.txt");345 assert!(escaped.starts_with("HTTP/1.1 404"), "{escaped}");346 assert!(!escaped.contains("SECRET"));347 }348 }