tests/review4_hardening.rs 11.4 KiB 348 lines Raw
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("&lt;repo&gt;") || 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 }