# CVE-2026-89094 — Forgejo <16.0.4 RCE via crafted template repository

## Summary

When a user generates a new repository from a template repository, Forgejo clones the template, deletes the `.git` directory, applies variable template expansion to the files listed in `.forgejo/template` (expanding variables in both file **content** and file **paths**, e.g. `.g${REPO_NAME}/config` → `.git/config` when the generated repository is named `it`), and then runs `git init` in that working directory. Because no cleanup happens after the expansion step, an attacker-controlled template can *recreate* a `.git` directory during expansion, and the subsequent `git init` adopts it. Forgejo then performs `git add --all` and `git commit` in that directory, honoring the attacker-supplied `.git/config` (e.g. `core.fsmonitor`, `core.hooksPath`) and hooks, which leads to arbitrary command execution on the Forgejo host as the Forgejo runtime user. The fix (PR #14301, released in v16.0.4) removes any `.git` directory again after the expansion completes and before `git init`.

## Impact

- **Package/component affected:** Forgejo `services/repository/generate_repo_commit.go` (repository generation from template), interacting with `services/repository/generate.go` (variable expansion).
- **Affected versions:** Forgejo < 16.0.4 (verified on the official container image `codeberg.org/forgejo/forgejo:16.0.3-rootless`, source tag `v16.0.3` = commit `eccddb2d17c93b42b2c8995725e03e549ac9ec0c`).
- **Risk level:** Critical (CVSS 3.1 AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H = 9.9). Any authenticated user who can create a template repository and generate a repository from it obtains arbitrary command execution in the Forgejo container/host as the Forgejo runtime user, plus arbitrary read (and write) of host data reachable by that user.

## Impact Parity

- **Disclosed/claimed maximum impact:** Remote code execution on the Forgejo host via a crafted template repository ("read arbitrary data from the Forgejo host and execute arbitrary processes, e.g. via git hooks/config such as core.fsmonitor or hooks").
- **Reproduced impact from this run:** Full remote code execution through the real product HTTP API: attacker-supplied `post-commit` hook executed as `uid=1000(git)` inside the Forgejo container during the generated repository's initial commit; the hook wrote an exact marker string, captured `id` output, and read `/etc/passwd` from the host filesystem (arbitrary host data read). Both the vulnerable (16.0.3) and fixed (16.0.4) builds were exercised end-to-end.
- **Parity: `full`**
- **Not demonstrated:** Nothing material; the claimed impact (arbitrary process execution + host data read) was demonstrated directly.

## Root Cause

In `services/repository/generate_repo_commit.go` (v16.0.3), `generateRepoCommit`:

1. Clones the template repository into a temp dir (`git.Clone`, depth 1).
2. Removes the cloned `.git` (`util.RemoveAll(path.Join(tmpDir, ".git"))`) **before** expansion.
3. Reads `.forgejo/template` (or `.gitea/template`) and, for every file matching one of its globs, expands variables in the content (`generateExpansion(content, ..., false)`) and in the path (`generateExpansion(relPath, ..., true)`, then `root.Rename(relPath, substPath)`). Since `${REPO_NAME}` is attacker-influenced (the name chosen for the generated repository), a template file named `.g${REPO_NAME}/config` is renamed to `.git/config` when the generated repo is named `it`.
4. Runs `git.InitRepository(ctx, tmpDir, ...)` — `git init` in a directory that already contains a `.git` directory reinitializes it and **preserves the attacker-written `config` and hooks**.
5. Runs `git remote add`, `git add --all`, `git commit` (`initRepoCommit`) — and git honors the adopted config.

Two execution vectors were validated:

- `core.fsmonitor` in the adopted `.git/config` executes during `git add --all` (git appends version/token arguments to the configured command).
- Repo-local `core.hooksPath = .ghooks` overrides Forgejo's *global* `core.hooksPath = /var/lib/gitea/home/hooks` (which is why a plain `.git/hooks/post-commit` file is silently ignored), so the attacker's `.ghooks/post-commit` (an ordinary template file that rides along) executes during `git commit`.

Fix: PR #14301 "fix: prevent template expansion from interfering with git initialization" (commit `0d74280e071a9ddc145930f93fa7b5cb50c2a4e8`, released in v16.0.4, tag `6e56b5ebad3fb05036b1ff68a6b47b80f5859c7c`) adds, after the expansion loop and before `git.InitRepository`:

```go
// Before template expansion, .git was removed so that a fresh repo can be initialized; remove it again in case
// some template variable usage has conflicted with this directory and impacts git operations.
if err := root.RemoveAll(".git"); err != nil {
    return fmt.Errorf("unable to remove .git folder")
}
```

## Reproduction Steps

1. Reference: `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker).
2. What the script does, per attempt (2× vulnerable 16.0.3-rootless, 2× fixed 16.0.4-rootless, each a fresh container on its own port):
   - Pulls/starts the official Forgejo container image, waits for `/api/healthz`.
   - Creates the admin user via the real `forgejo admin user create` CLI and an API token.
   - Creates a repository `evil-template` via `POST /api/v1/user/repos`, marks it as a template via `PATCH /api/v1/repos/admin1/evil-template` (`{"template":true}`).
   - Builds and pushes the malicious template over the real git-HTTP receive path: `README.md`, `.forgejo/template` (globs `README.md`, `.g*/config`, `.ghooks/*`), `.g${REPO_NAME}/config` (repo-local `core.hooksPath = .ghooks` + `core.fsmonitor` fallback), executable `.ghooks/post-commit` (writes an exact marker string, `id` output, and `/etc/passwd` into `/tmp` inside the container).
   - Waits until the template content is registered, then triggers the vulnerability through the real product workflow: `POST /api/v1/repos/admin1/evil-template/generate` with `{"name":"it","owner":"admin1","git_content":true}` so that `${REPO_NAME}` = `it` turns `.g${REPO_NAME}/config` into `.git/config`.
   - Collects per-attempt evidence: HTTP request/response transcripts, marker files, hook `id` output, exfiltrated `/etc/passwd`, generated repo README, and the full Forgejo service log.
3. Expected evidence of reproduction:
   - Vulnerable attempts: `generate` returns 201; `/tmp/marker-<tag>` inside the container contains exactly `CVE-2026-89094-RCE-<tag>`; `/tmp/rce-id-<tag>` contains `uid=1000(git) ...`; `/tmp/hostdata-<tag>` contains the container's `/etc/passwd`; generated repo still serves `README.md` == `Hello!`.
   - Fixed attempts (16.0.4): same attacker procedure; marker/hook/hostdata files absent; generation still succeeds with `README.md` == `Hello!`.

## Evidence

All artifacts under `bundle/` (SHA-256 digests in `bundle/repro/runtime_manifest.json`):

- `bundle/logs/vuln-1/`, `bundle/logs/vuln-2/` — vulnerable attempts:
  - `http_generate_request.txt`, `http_generate_response.json`, `http_generate_status.txt` — the triggering API call (`HTTP 201`).
  - `marker.txt` — exact bytes `CVE-2026-89094-RCE-vuln-1` / `...-vuln-2` written by the attacker hook.
  - `rce_id.txt` — `uid=1000(git) gid=1000(git) groups=1000(git)` (command executed as the Forgejo runtime user).
  - `hostdata_etc_passwd.txt` — 18 lines of `/etc/passwd` read from the Forgejo container host filesystem.
  - `forgejo_service.log` — full product log including the `POST /api/v1/repos/admin1/evil-template/generate ... 201` router line.
  - `template_tree.txt` — git tree of the malicious template actually pushed (`.g${REPO_NAME}/config` mode 100644, `.ghooks/post-commit` mode 100755).
- `bundle/logs/fixed-1/`, `bundle/logs/fixed-2/` — fixed control attempts: `marker.txt`/`rce_id.txt` contain `__ABSENT__`, `generated_readme.txt` == `Hello!`, `http_generate_status.txt` == `HTTP 201`.
- `bundle/repro/runtime_manifest.json` — runtime stack, image digests (`sha256:214f4ae6...` vulnerable / `sha256:a263a129...` fixed), source commits, and artifact digests.
- `bundle/logs/reproduction_steps.log` — console transcript of both verification runs.

Environment: Docker on linux/x86_64; images `codeberg.org/forgejo/forgejo:16.0.3-rootless` (source `v16.0.3`, `eccddb2d17c93b42b2c8995725e03e549ac9ec0c`) and `codeberg.org/forgejo/forgejo:16.0.4-rootless` (source `v16.0.4`, `6e56b5ebad3fb05036b1ff68a6b47b80f5859c7c`); Forgejo runs sqlite3; repo push over git-HTTP with a token-authenticated admin session.

## Recommendations / Next Steps

- **Upgrade to Forgejo v16.0.4** (or later), which removes any `.git` directory after template expansion and before `git init` (PR #14301).
- Defense in depth for deployers: none of Forgejo's process-level settings prevent this once the bug is present; upgrading is the fix. The execution lands as the Forgejo runtime user — containerize/isolate and drop unneeded privileges.
- Testing recommendations: the upstream integration test `TestRepoGenerateTemplatingDotGitDir` covers the `.git/config` recreation; add a case asserting that *executable hook files* and `core.hooksPath`/`core.fsmonitor` values delivered through template expansion never influence the generated repository's git operations.

## Additional Notes

- **Idempotency:** the script was executed twice consecutively; both runs ended with `VERDICT: CONFIRMED` (exit 0). It removes containers/data dirs by name before each attempt, so repeated runs are clean.
- Two important mechanics discovered while validating:
  - Forgejo sets a **global** `core.hooksPath` (`/var/lib/gitea/home/hooks`) for its git subprocesses, so a naive `.git/hooks/post-commit` payload is silently ignored; the working payload overrides it with a repo-local `core.hooksPath` pointing at an ordinary template directory (`.ghooks`). `core.fsmonitor` in the adopted config is an independent (also validated interactively) execution vector.
  - After a git push, the repository API's `empty` field disappears (omitempty) once non-empty, so readiness must be polled via `size`/raw-file availability — otherwise a generate issued too early is silently skipped (`opts.GitContent && !templateRepo.IsEmpty`).
- Limitations: the marker/hook evidence is collected from the container filesystem via `docker exec` (the attacker-relevant boundary — template push + generate — is fully remote over HTTP). The execution user is the containerized Forgejo runtime user (`uid=1000 git`), not root, which matches the disclosed impact scope.
