.forge/prs/44ea71b1-a7ab-408b-8618-92b4dbbe0e85/patch.diff 12.4 KiB 360 lines Raw
1 From 1c7055f0cf0b3f183e65373112f23425c14871ae Mon Sep 17 00:00:00 20012 From: hu13-test <[email protected]>3 Date: Tue, 27 Oct 2026 15:33:24 +00004 Subject: [PATCH] refuse forge frontmatter paths that escape out/ (#60)5 6 Issue/PR id and release version become path segments7 (issues/<id>.html, releases/<v>.html, releases/<v>/), so a8 '../../tmp/x' value wrote outside the build output. Validate ids9 ([A-Za-z0-9_-]) and versions (single safe segment) at load time,10 guard write_page() with a lexical relative-path check, and refuse11 unsafe release versions in render too. Refused entries warn and are12 skipped; the build still succeeds.13 ---14  src/forge.rs         |  13 +++++15  src/main.rs          |   1 +16  src/paths.rs         |  74 ++++++++++++++++++++++++17  src/releases.rs      |  43 ++++++++------18  tests/path_safety.rs | 134 +++++++++++++++++++++++++++++++++++++++++++19  5 files changed, 248 insertions(+), 17 deletions(-)20  create mode 100644 src/paths.rs21  create mode 100644 tests/path_safety.rs22 23 diff --git a/src/forge.rs b/src/forge.rs24 index 6044b16..dda2cf3 10064425 --- a/src/forge.rs26 +++ b/src/forge.rs27 @@ -579,6 +579,10 @@ fn load_issues(root: &Path) -> Vec<Issue> {28          let Some(fm): Option<IssueFm> = parse_frontmatter(yaml, &label) else {29              continue;30          };31 +        if !crate::paths::is_safe_id(&fm.id) {32 +            eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);33 +            continue;34 +        }35          let comments = load_comments(36              &dir.join(&uuid).join("comments"),37              &format!("issues/{uuid}/comments"),38 @@ -620,6 +624,10 @@ fn load_prs(root: &Path) -> Vec<Pr> {39          let Some(fm): Option<PrFm> = parse_frontmatter(yaml, &label) else {40              continue;41          };42 +        if !crate::paths::is_safe_id(&fm.id) {43 +            eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);44 +            continue;45 +        }46          let patch = std::fs::read(dir.join(&uuid).join("patch.diff"))47              .ok()48              .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());49 @@ -1278,6 +1286,11 @@ fn render_ci_index(50  }51  52  fn write_page(out: &Path, rel: &str, html: &str) -> Result<(), String> {53 +    if !crate::paths::is_safe_relative(rel) {54 +        return Err(format!(55 +            "hu13: refusing to write unsafe output path `{rel}`"56 +        ));57 +    }58      let dest = out.join(rel);59      if let Some(parent) = dest.parent() {60          std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create {rel} parent: {e}"))?;61 diff --git a/src/main.rs b/src/main.rs62 index 148c678..d2ee742 10064463 --- a/src/main.rs64 +++ b/src/main.rs65 @@ -8,6 +8,7 @@ mod gitx;66  mod inbox;67  mod layout;68  mod logo;69 +mod paths;70  mod releases;71  mod serve;72  mod wiki;73 diff --git a/src/paths.rs b/src/paths.rs74 new file mode 10064475 index 0000000..437caaa76 --- /dev/null77 +++ b/src/paths.rs78 @@ -0,0 +1,74 @@79 +//! Output-path safety (f0rge #60).80 +//!81 +//! Every path that becomes a file under the build output dir must be82 +//! checked here first: `.forge/` frontmatter (`id`, release `version`) is83 +//! untrusted input, and `out.join("issues/{id}.html")` with84 +//! `id = "../../tmp/x"` writes outside `out/`.85 +//!86 +//! Rules: no absolute paths, no `\` or NUL, no empty/`.`/`..` segments.87 +//! The checks are lexical on purpose — they run before anything is88 +//! created, so there is nothing to canonicalize yet.89 +90 +/// `true` when `segment` is a single safe path component.91 +#[must_use]92 +pub fn is_safe_segment(segment: &str) -> bool {93 +    !segment.is_empty() && segment != "." && segment != ".." && !segment.contains(['/', '\\', '\0'])94 +}95 +96 +/// `true` when `rel` is a relative path made only of safe segments.97 +#[must_use]98 +pub fn is_safe_relative(rel: &str) -> bool {99 +    !rel.is_empty()100 +        && !rel.starts_with('/')101 +        && !rel.contains(['\\', '\0'])102 +        && rel.split('/').all(is_safe_segment)103 +}104 +105 +/// `true` when `id` is safe both as a path segment and as a displayed106 +/// forge id: ASCII alphanumerics, `-`, `_` only.107 +#[must_use]108 +pub fn is_safe_id(id: &str) -> bool {109 +    is_safe_segment(id)110 +        && id111 +            .bytes()112 +            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')113 +}114 +115 +#[cfg(test)]116 +mod tests {117 +    use super::*;118 +119 +    #[test]120 +    fn rejects_traversal_and_absolute() {121 +        for bad in [122 +            "..",123 +            "../x",124 +            "a/../../b",125 +            "/etc/passwd",126 +            "a\\b",127 +            "a\0b",128 +            "",129 +            "a//b",130 +            "./a",131 +            "a/",132 +        ] {133 +            assert!(!is_safe_relative(bad), "{bad:?} must be rejected");134 +        }135 +    }136 +137 +    #[test]138 +    fn accepts_normal_relative_paths() {139 +        for ok in ["a", "a/b", "a-b_c.html", "issues/1.html"] {140 +            assert!(is_safe_relative(ok), "{ok:?} must be accepted");141 +        }142 +    }143 +144 +    #[test]145 +    fn ids_are_strict() {146 +        assert!(is_safe_id("06b5602f-4327-4377-99f6-3dca67b5bd75"));147 +        assert!(is_safe_id("PWNED2"));148 +        for bad in ["../../x", "a/b", "a b", "a.b", "..", "", "a\\b"] {149 +            assert!(!is_safe_id(bad), "{bad:?} must be rejected");150 +        }151 +    }152 +}153 diff --git a/src/releases.rs b/src/releases.rs154 index 03ca8e5..12fbf5a 100644155 --- a/src/releases.rs156 +++ b/src/releases.rs157 @@ -261,20 +261,23 @@ pub fn load(repo: &Path) -> Vec<Release> {158                  continue;159              }160          };161 +        // `version` becomes a path segment (`releases/<v>.html`,162 +        // `releases/<v>/`): untrusted frontmatter, so reject traversal163 +        // loudly instead of writing outside the output dir (f0rge #60).164 +        if !crate::paths::is_safe_segment(&fm.version) {165 +            eprintln!(166 +                "hu13: releases: skipping {label}: unsafe version {:?}",167 +                fm.version168 +            );169 +            continue;170 +        }171          // Artifacts: present only when `out/<version>/` exists at build.172          // Absent -> notes-only, no warning.173 -        let artifacts = if fm.version.contains('/')174 -            || fm.version.contains('\\')175 -            || fm.version.split('/').any(|p| p == "..")176 -        {177 -            Vec::new()178 +        let out_dir = root.join("out").join(&fm.version);179 +        let artifacts = if out_dir.is_dir() {180 +            collect_artifacts(&out_dir)181          } else {182 -            let out_dir = root.join("out").join(&fm.version);183 -            if out_dir.is_dir() {184 -                collect_artifacts(&out_dir)185 -            } else {186 -                Vec::new()187 -            }188 +            Vec::new()189          };190          out.push(Release {191              version: fm.version,192 @@ -424,13 +427,19 @@ pub fn render_loaded(193      std::fs::write(&dest, index).map_err(|e| format!("hu13: write releases index: {e}"))?;194  195      for rel in releases {196 +        // `load` already rejected unsafe versions; keep the guard here197 +        // too so a future caller cannot bypass it (defense in depth).198 +        if !crate::paths::is_safe_segment(&rel.version) {199 +            return Err(format!(200 +                "hu13: refusing to write release with unsafe version `{}`",201 +                rel.version202 +            ));203 +        }204          // Copy the whole out dir when present (no size cap).205 -        if !rel.version.contains('/') && !rel.version.contains('\\') {206 -            let src_dir = root.join("out").join(&rel.version);207 -            if src_dir.is_dir() {208 -                let dest_dir = out.join("releases").join(&rel.version);209 -                copy_tree(&src_dir, &dest_dir)?;210 -            }211 +        let src_dir = root.join("out").join(&rel.version);212 +        if src_dir.is_dir() {213 +            let dest_dir = out.join("releases").join(&rel.version);214 +            copy_tree(&src_dir, &dest_dir)?;215          }216          let html = render_release_page(rel, site_name, nav, branch, short_sha, description);217          let page_dest = out.join("releases").join(format!("{}.html", rel.version));218 diff --git a/tests/path_safety.rs b/tests/path_safety.rs219 new file mode 100644220 index 0000000..d56023f221 --- /dev/null222 +++ b/tests/path_safety.rs223 @@ -0,0 +1,134 @@224 +//! f0rge #60: `.forge` frontmatter is untrusted and must never escape the225 +//! output dir. Issue/PR `id` and release `version` become path segments226 +//! (`issues/<id>.html`, `releases/<version>.html`, `releases/<version>/`),227 +//! so a `../../..` value used to write outside `-o`.228 +229 +use std::path::{Path, PathBuf};230 +use std::process::Command;231 +232 +fn git(repo: &Path, args: &[&str]) {233 +    let st = Command::new("git")234 +        .arg("-C")235 +        .arg(repo)236 +        .args(args)237 +        .status()238 +        .expect("spawn git");239 +    assert!(st.success(), "git {args:?} failed");240 +}241 +242 +fn fresh_dir(prefix: &str) -> PathBuf {243 +    let dir = std::env::temp_dir().join(format!(244 +        "hu13-paths-{prefix}-{}-{}",245 +        std::process::id(),246 +        std::time::SystemTime::now()247 +            .duration_since(std::time::UNIX_EPOCH)248 +            .map(|d| d.as_nanos())249 +            .unwrap_or(0)250 +    ));251 +    std::fs::create_dir_all(&dir).unwrap();252 +    dir253 +}254 +255 +fn write(repo: &Path, rel: &str, content: &str) {256 +    let dest = repo.join(rel);257 +    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();258 +    std::fs::write(&dest, content).unwrap();259 +}260 +261 +#[test]262 +fn hostile_frontmatter_cannot_write_outside_out() {263 +    let repo = fresh_dir("repo");264 +    let out = fresh_dir("out");265 +    let marker = format!(266 +        "hu13-pwn-{}-{}",267 +        std::process::id(),268 +        std::time::SystemTime::now()269 +            .duration_since(std::time::UNIX_EPOCH)270 +            .map(|d| d.as_nanos())271 +            .unwrap_or(0)272 +    );273 +    let escaped = |name: &str| PathBuf::from(format!("/tmp/{marker}-{name}.html"));274 +275 +    git(&repo, &["init", "-b", "main"]);276 +    git(&repo, &["config", "user.name", "hu13-test"]);277 +    git(&repo, &["config", "user.email", "[email protected]"]);278 +    git(&repo, &["config", "commit.gpgsign", "false"]);279 +    write(&repo, "README.md", "# path safety\n");280 +281 +    // Issue whose id escapes 6 levels (out lives in /tmp, so /../../.. > /).282 +    write(283 +        &repo,284 +        ".forge/issues/aaaaaaaa-1111-4111-8111-111111111111/issue.md",285 +        &format!(286 +            "---\nid: ../../../../../../tmp/{marker}-issue\ntitle: pwn\n\287 +             state: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"288 +        ),289 +    );290 +    // PR whose id escapes.291 +    write(292 +        &repo,293 +        ".forge/prs/bbbbbbbb-2222-4222-8222-222222222222/pr.md",294 +        &format!(295 +            "---\nid: ../../../../../../tmp/{marker}-pr\ntitle: pwn\n\296 +             state: open\nbase: main\nhead: x\ncreated: 2026-09-10T03:00:00Z\n---\nd\n"297 +        ),298 +    );299 +    // Release whose version is absolute / traversal.300 +    write(301 +        &repo,302 +        ".forge/releases/r1.md",303 +        &format!(304 +            "---\nversion: /tmp/{marker}-release\ntitle: r1\n\305 +             created: 2026-09-10T03:00:00Z\n---\nnotes\n"306 +        ),307 +    );308 +    write(309 +        &repo,310 +        ".forge/releases/r2.md",311 +        "---\nversion: ../../../../../../tmp/{marker}-dotdot\ntitle: r2\n\312 +         created: 2026-09-10T03:00:00Z\n---\nnotes\n",313 +    );314 +    // A valid release still has to render.315 +    write(316 +        &repo,317 +        ".forge/releases/r3.md",318 +        "---\nversion: v1.0.0\ntitle: good\ncreated: 2026-09-10T04:00:00Z\n---\nnotes\n",319 +    );320 +321 +    git(&repo, &["add", "-A"]);322 +    git(&repo, &["commit", "-m", "hostile fixture"]);323 +324 +    let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))325 +        .arg("build")326 +        .arg(&repo)327 +        .arg("-o")328 +        .arg(&out)329 +        .output()330 +        .expect("spawn hu13 build");331 +    let stderr = String::from_utf8_lossy(&produced.stderr);332 +    assert!(333 +        produced.status.success(),334 +        "hostile frontmatter must not fail the build: {stderr}"335 +    );336 +337 +    for name in ["issue", "pr", "release", "dotdot"] {338 +        let p = escaped(name);339 +        assert!(340 +            !p.exists(),341 +            "build wrote outside out/: {} exists",342 +            p.display()343 +        );344 +    }345 +    // Nothing landed at /tmp directly either (traversal is blocked, not346 +    // merely clamped).347 +    assert!(!Path::new(&format!("/tmp/{marker}-issue.html")).exists());348 +349 +    // Refused entries are warned about, naming the bad value.350 +    assert!(stderr.contains("unsafe id"), "stderr: {stderr}");351 +    assert!(stderr.contains("unsafe version"), "stderr: {stderr}");352 +353 +    // Valid content survives.354 +    assert!(out.join("releases/v1.0.0.html").is_file());355 +    // Build still completes with an output tree.356 +    assert!(out.join("index.html").is_file());357 +}358 -- 359 2.56.0360