A Forgejo Template Repo Could Plant Its Own .git Folder and Git Would Adopt It
Forgejo 16.0.4 fixes a CVSS 9.9 remote code execution bug that needs no exploit code, just a directory name. We reproduced the git primitive underneath it in about four minutes.
The WJS Desk
Sep 11, 2026 · 6 min read

Forgejo shipped 16.0.4 and 15.0.8 on 10 September, and the release notes carry a line that starts with the word Critical. NVD published CVE-2026-89094 the same day with a CVSS 3.1 base score of 9.9, vector AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H. That is about as close to the ceiling as a scored vulnerability gets.
What made us stop and read it twice is that there is no exploit code. There is no buffer, no deserialisation gadget, no clever encoding. The entire attack is a folder with a carefully chosen name. We spent an afternoon pulling the patch apart and reproducing the piece of it that lives in git rather than in Forgejo, and the git half took about four minutes.
What actually shipped
Forgejo pre-announced the patch on 5 September in its security-announcements tracker, then released on the 10th. Two versions got it: 16.0.4 on the current line, and 15.0.8 on the v15 LTS line, which matters because a lot of self-hosters pin to LTS and assume they are not on the hook.
The release actually contains three security fixes, not one, and only the first got a CVE:
| Fix | What it allowed | Severity |
|---|---|---|
Template expansion recreates .git | Arbitrary file read plus code execution on the host | Critical, CVE-2026-89094, CVSS 9.9 |
| Maintainer-edit check ignored API token scope | A repo-scoped token writing outside its scope | Unscored, listed as a security bug fix |
| Draft release attachments served without a draft check | Unauthenticated download of unreleased binaries | Unscored, same class as Gitea CVE-2026-27660 |
That third one deserves more attention than it got. Draft releases are where you stage a binary before you announce it. Forgejo's GetReleaseAttachment endpoint and its web download route did not check whether the parent release was a draft, while GetRelease and ListReleaseAttachments already did. Unauthenticated callers on a public repo could pull the file. Nobody on the thread mentioned it.
The trick is a folder that expands into .git
Forgejo's template feature works like this. You mark a repo as a template, and you add a .forgejo/template file listing which paths should have variables substituted into them. When somebody generates a new repo from your template, Forgejo clones it, deletes the .git folder, runs variable expansion over the listed files, and then calls git init on what is left.
The order there is the bug. Expansion happens after the .git removal and before the init.
The regression test Forgejo added alongside the fix spells the technique out more clearly than the advisory does. It builds a template containing a file at .g${REPO_NAME}/config, and a .forgejo/template listing the glob .g*/config. Then it generates a repo named it. Expansion turns .g${REPO_NAME} into .git, and Forgejo hands git a directory that already contains a .git folder the attacker wrote.
// from tests/integration/repo_generate_test.go
Files: forgery.MapFS{
"README.md": forgery.MapFile("Hello!"),
".forgejo/template": forgery.MapFile("README.md\n.g*/config"),
// if this reached .git/config, because REPO_NAME is going to be "it",
// it would break template generation
".g${REPO_NAME}/config": forgery.MapFile("[core]\n compression = 999"),
}
The fix, in services/repository/generate_repo_commit.go, is six lines: delete .git a second time, after expansion, before init.
We reproduced the git half of it
The Forgejo-specific part needs a running Forgejo and an account, so we did not try it. What we could test on our own machine is the assumption the whole attack rests on: that git init will happily adopt a .git directory somebody else put there.
We made a directory, hand-wrote a minimal .git inside it (a HEAD, a config, empty objects and refs), dropped an executable post-commit hook in .git/hooks, and ran git init on git 2.50.1.
$ git init
Reinitialized existing Git repository in /private/tmp/gitadopt/work/.git/
$ ls .git/hooks/post-commit
.git/hooks/post-commit # survived
$ git add README.md && git commit -m "first commit"
$ cat /tmp/gitadopt/PWNED
HOOK EXECUTED as cvocampo at 13:03:47
Git did not warn, did not refuse, and did not clear the hooks directory. It printed Reinitialized existing Git repository, which is git correctly doing what it has always documented, and the planted hook ran as our user on the very first commit. On a Forgejo host, that user is the Forgejo service account.
Worth knowing: this is not a Forgejo-only pattern. Any system that assembles a directory from untrusted content and then runs git init in it has the same shape of problem. If you have CI that scaffolds a workspace before initialising a repo, that is the same bug waiting for a different advisory.
What the thread argued about
The Hacker News discussion reached 196 points and 76 comments, and for the first few hours almost nobody could read the advisory: Codeberg was rate-limiting the release notes page. rcleveng posted the error verbatim, "This git endpoint is seeing a high influx of requests for this repository, to preserve the availability of Codeberg your search request will not be processed", and asked for a mirror. Several commenters ended up pasting the release notes into the thread by hand so people could see what they were patching. A critical advisory that your users cannot load is most of a disclosure problem on its own.
The sharpest dissent came from dboreham, who pushed back on the headline:
"Quick note that this is not an RCE in the typical sense that I can rock up to a Forgejo installation and execute code on the server. The attack vector here is via a template repo specified when initializing a new repo. So if you don't create new repos from mystery meat template repos, you should be ok."
That is a fair correction and the CVSS vector partly agrees with it: PR:L means the attacker needs some privilege, and UI:N notwithstanding, somebody has to generate a repo from the hostile template. It is not wormable. It is also not much comfort on a public instance where anybody can publish a template and write a README telling people what to name their new repo.
techknowlogick, who is part of Gitea's project leadership, posted to say Gitea is not affected by either issue, and then added the more useful half:
"security incidents happen to everyone and we shouldn't shame anyone for reporting them, especially as that'd otherwise cause less issues to be reported overall."
The best comment in the thread got no traction at all. tugback wrote one line: "Removing .git after expansion seems fragile. Sandboxing the git step would close the whole class of bugs." That is correct and it is the thing the patch does not do. The current fix is a second RemoveAll. It closes this path. It does not close the next one, because the design still hands a directory of attacker-influenced content to a tool that trusts directories.
nirui read the patch and caught something smaller but real: the new error path returns fmt.Errorf("unable to remove .git folder") and drops the underlying err entirely, so an operator hitting it has nothing to debug with.
The part that will get argued about for months
Two commenters, keel-control and rvz, connected the advisory to Forgejo's policy of not accepting AI-generated contributions, which the project confirmed earlier this year. Their argument: attackers will use models to find bugs like this whether or not defenders do, so a total ban is unilateral disarmament.
Nothing about this bug suggests a model would have caught it, and nothing suggests one would have missed it.
We think that framing is doing a lot of work it has not earned, in both directions. This is a logic-ordering bug in twenty lines of Go, found by a human, patched by humans, with a regression test that reads like it was written by somebody who understood the primitive. It is a clean data point for neither side of that argument, and it is being recruited into it anyway.
What to do
If you self-host Forgejo, patch to 16.0.4 or 15.0.8. If you are on 15 LTS, you are affected, and miguelgrinberg flagged that on the thread because it is easy to miss in the notes. Follow the security-announcements tracker, as PaoloBarbolini suggested, since that is where the pre-announcement landed five days before the release.
Then go and look at anything else you run that builds a directory and calls git init on it. We reproduced the primitive in four minutes with no special tooling. So can everyone else.


