tests/path_safety.rs 4.5 KiB 140 lines Raw
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 }