1 //! f0rge #60: `.forge` frontmatter is untrusted and must never escape the2 //! output dir. Issue/PR `id` and release `version` become path segments3 //! (`issues/<id>.html`, `releases/<version>.html`, `releases/<version>/`),4 //! so a `../../..` value used to write outside `-o`.5 6 use std::sync::atomic::{AtomicU64, Ordering};7 8 static SEQ: AtomicU64 = AtomicU64::new(0);9 10 use std::path::{Path, PathBuf};11 use std::process::Command;12 13 fn git(repo: &Path, args: &[&str]) {14 let st = Command::new("git")15 .arg("-C")16 .arg(repo)17 .args(args)18 .status()19 .expect("spawn git");20 assert!(st.success(), "git {args:?} failed");21 }22 23 fn fresh_dir(prefix: &str) -> PathBuf {24 let dir = std::env::temp_dir().join(format!(25 "hu13-paths-{prefix}-{}-{}",26 std::process::id(),27 std::time::SystemTime::now()28 .duration_since(std::time::UNIX_EPOCH)29 .map(|d| d.as_nanos())30 .unwrap_or(0)31 .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))32 ));33 std::fs::create_dir_all(&dir).unwrap();34 dir35 }36 37 fn write(repo: &Path, rel: &str, content: &str) {38 let dest = repo.join(rel);39 std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();40 std::fs::write(&dest, content).unwrap();41 }42 43 #[test]44 fn hostile_frontmatter_cannot_write_outside_out() {45 let repo = fresh_dir("repo");46 let out = fresh_dir("out");47 let marker = format!(48 "hu13-pwn-{}-{}",49 std::process::id(),50 std::time::SystemTime::now()51 .duration_since(std::time::UNIX_EPOCH)52 .map(|d| d.as_nanos())53 .unwrap_or(0)54 .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))55 );56 let escaped = |name: &str| PathBuf::from(format!("/tmp/{marker}-{name}.html"));57 58 git(&repo, &["init", "-b", "main"]);59 git(&repo, &["config", "user.name", "hu13-test"]);60 git(&repo, &["config", "user.email", "[email protected]"]);61 git(&repo, &["config", "commit.gpgsign", "false"]);62 write(&repo, "README.md", "# path safety\n");63 64 // Issue whose id escapes 6 levels (out lives in /tmp, so /../../.. > /).65 write(66 &repo,67 ".forge/issues/aaaaaaaa-1111-4111-8111-111111111111/issue.md",68 &format!(69 "---\nid: ../../../../../../tmp/{marker}-issue\ntitle: pwn\n\70 state: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"71 ),72 );73 // PR whose id escapes.74 write(75 &repo,76 ".forge/prs/bbbbbbbb-2222-4222-8222-222222222222/pr.md",77 &format!(78 "---\nid: ../../../../../../tmp/{marker}-pr\ntitle: pwn\n\79 state: open\nbase: main\nhead: x\ncreated: 2026-09-10T03:00:00Z\n---\nd\n"80 ),81 );82 // Release whose version is absolute / traversal.83 write(84 &repo,85 ".forge/releases/r1.md",86 &format!(87 "---\nversion: /tmp/{marker}-release\ntitle: r1\n\88 created: 2026-09-10T03:00:00Z\n---\nnotes\n"89 ),90 );91 write(92 &repo,93 ".forge/releases/r2.md",94 "---\nversion: ../../../../../../tmp/{marker}-dotdot\ntitle: r2\n\95 created: 2026-09-10T03:00:00Z\n---\nnotes\n",96 );97 // A valid release still has to render.98 write(99 &repo,100 ".forge/releases/r3.md",101 "---\nversion: v1.0.0\ntitle: good\ncreated: 2026-09-10T04:00:00Z\n---\nnotes\n",102 );103 104 git(&repo, &["add", "-A"]);105 git(&repo, &["commit", "-m", "hostile fixture"]);106 107 let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))108 .arg("build")109 .arg(&repo)110 .arg("-o")111 .arg(&out)112 .output()113 .expect("spawn hu13 build");114 let stderr = String::from_utf8_lossy(&produced.stderr);115 assert!(116 produced.status.success(),117 "hostile frontmatter must not fail the build: {stderr}"118 );119 120 for name in ["issue", "pr", "release", "dotdot"] {121 let p = escaped(name);122 assert!(123 !p.exists(),124 "build wrote outside out/: {} exists",125 p.display()126 );127 }128 // Nothing landed at /tmp directly either (traversal is blocked, not129 // merely clamped).130 assert!(!Path::new(&format!("/tmp/{marker}-issue.html")).exists());131 132 // Refused entries are warned about, naming the bad value.133 assert!(stderr.contains("unsafe id"), "stderr: {stderr}");134 assert!(stderr.contains("unsafe version"), "stderr: {stderr}");135 136 // Valid content survives.137 assert!(out.join("releases/v1.0.0.html").is_file());138 // Build still completes with an output tree.139 assert!(out.join("index.html").is_file());140 }