---
id: 0c944797-1025-43bb-a382-686669c85c69
title: 'Build-time path traversal: forge frontmatter id/version writes outside the output dir'
state: closed
labels:
- security
assignees: []
milestone: null
created: 2026-10-27T14:53:25.602484238Z
updated: 2026-10-27T15:34:16.634502262Z
---
`hu13 build` writes forge pages using frontmatter values as path segments without validation, so a repo can write **arbitrary files outside the output directory**.

Reproductions (all verified on master):

```
# .forge/issues/aaaa/issue.md with  id: ../../../../../../tmp/PWNED
$ hu13 build . -o /tmp/trav-out
$ ls /tmp/PWNED.html
-rw-r--r-- 1 z z 1870 /tmp/PWNED.html

# .forge/prs/bbbb/pr.md with  id: ../../../../../../tmp/PWNED2   -> /tmp/PWNED2.html
# .forge/releases/r1.md with  version: /tmp/PWNED3              -> /tmp/PWNED3.html
```

Root causes:
- `forge::render_loaded` -> `write_page(out, &format!("issues/{}.html", issue.id))` and the same for `pulls/{}.html`. `issue.id`/`pr.id` come straight from frontmatter (`fm.id`) and are never validated.
- `releases::render_loaded` -> `out.join("releases").join(format!("{}.html", rel.version))`. `releases::load` guards *artifacts* against `/`, `\`, `..`, but `fm.version` itself is unvalidated — and `Path::join` with an absolute component discards `out` entirely.

Impact: `build` runs in CI/Docker and writes with its own permissions. A hostile PR (or a repo you mirror but do not control) can drop attacker-influenced HTML anywhere the build user can write: overwrite other build outputs, plant files in a served tree, clobber dotfiles/config in the workspace. Because the content is a full issue/PR page, the body text is attacker-controlled (escaped HTML, but still attacker-chosen bytes and filename).

Fix:
- validate every frontmatter-used-as-path value: issue/pr `id` must match `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` (the f0rge uuid shape), release `version` must reject `/`, `\`, absolute paths and `..` components
- defense in depth: one `write_page` helper that rejects any `rel` containing a `..` component or an absolute prefix, and asserts the final path stays under `out`
- tests: traversal fixtures for issue id, pr id, release version; build must fail (or skip) and must not create the outside file

Note: this predates #54 and is independent of it. It is the most serious finding of the review so far.
