On 10 September, Forgejo shipped 16.0.4 and 15.0.8, and the release notes lead with a critical remote code execution fix. The usual response is to patch and move on. It is worth reading further, because the same release contains three more authorisation failures, and all four are the same bug wearing different clothes. In every case the control existed, had been written correctly, and simply was not sitting on the path an attacker would use. That is a more useful finding than any single CVE, because it describes a defect class that every team with a growing API surface is quietly accumulating.
TL;DR
- Forgejo 16.0.4 and 15.0.8 (10 September 2026) fix a critical RCE in repository template generation, plus a scoped-token authorisation bypass. Versions 16.0.3 and earlier are affected.
- The RCE is built from three individually correct steps. Template variable expansion can plant a
.gitfolder, andgit initthen adopts it, which turns config keys and hooks into code execution on the host. - Three further permission defects in the same release share one root cause: the check exists on some paths to an object and not others. A draft release attachment was readable by unauthenticated callers through the web route while the API route checked correctly.
- OSV returns 193 advisories for Gitea’s module path and zero for Forgejo’s. Your dependency scanner reads lockfiles, and your git forge is not in one.
- The durable fix is to audit by object rather than by endpoint, and to put every self-hosted service outside your lockfiles on a named patch rota with a named owner.
A remote code execution built entirely out of correct steps
Forgejo can generate a new repository from a template repository. The sequence is: clone the template, delete its .git folder, run variable expansion over the files listed in .forgejo/template, then run git init on the result.
Read that ordering again. The .git removal happens before expansion. The git init happens after. Template expansion writes files. If expansion can be coerced into writing a directory named .git with contents of the attacker’s choosing, then git init finds one already present when it runs, and git init on a directory that already contains a .git folder does not error and does not start clean. It adopts what it finds. That behaviour is documented, deliberate, and relied upon by anyone who has ever re-run git init inside an existing checkout.
A .git folder is not inert data. It carries config and hooks. Several config keys nominate programs to execute, and hooks are programs. Forgejo’s own notes describe the outcome as reading arbitrary data from the host and executing arbitrary processes on it. In practice, anyone who can publish a template repository your users might generate from gets code execution on the machine holding every repository you own.
None of the three steps is wrong on its own. The vulnerability lives entirely in the ordering, in the gap between a control and a path that reaches past it. The fix (PR 14301 on the 16.0 branch, 14302 on 15.0) changes none of the three steps. It simply removes .git a second time, after expansion.
Then it happens three more times
The scoped token. Forgejo has a special case that lets repository maintainers edit branches they do not own when an open pull request carries the “allow maintainer edit” option. A reasonable feature, written against the caller that existed at the time: a logged-in human with repository-level permissions. Repository-scoped API tokens arrived later, and the special case never learned about them. A token you deliberately restricted could therefore make modifications outside that restriction. The check was correct for one class of caller and silently absent for another.
The draft release. Draft releases are hidden from people without write access. GetRelease and ListReleaseAttachments check draft status before answering. GetReleaseAttachment and the web attachment download route did not. Anyone with read access, including unauthenticated callers on a public repository, could retrieve the metadata and the full contents of an attachment belonging to a release that was not supposed to be visible at all. One object, four ways in, two of them guarded. Forgejo’s notes flag this as the same class Gitea fixed upstream in GHSA-q9pg-jj6x-j9p6, an advisory published in July and patched in Gitea 1.27.0. Worth noting in passing that the release notes cite one CVE identifier for that advisory while GitHub’s database records another, which is a preview of the next section.
The admin team. Filed under ordinary bug fixes rather than security: an organisation team set to admin was being granted owner permission over its related repositories. Admin and owner are separate levels for a reason, and on a typical installation this is arguably the most consequential item in the release. It is not in the security section because nobody reported it as a vulnerability. It was reported as behaviour that looked odd.
Your scanner is not going to tell you about any of this
Here is the genuinely awkward part. We queried OSV, the open source vulnerability database most scanners consult, on 11 September. Under code.gitea.io/gitea it returns 193 advisories. Under forgejo.org, the module path Forgejo actually publishes, it returns zero.
That is not a claim that Forgejo is more secure than Gitea. It is the absence of a claim in either direction: the tooling has no records to match against. Forgejo publishes its security fixes as prose in release notes on Codeberg, frequently without a CVE at all. Your dependency scanner reads your lockfiles. Your git forge is not in a lockfile. It is a container tag or a binary on a VM, and nothing in your pipeline is comparing its version against a feed that would fire.
That generalises well past this one project. The parts of your estate that automated scanning covers are the parts declared in a manifest. Everything installed as infrastructure sits outside that boundary: the forge, the CI runner, the artefact registry, the identity provider. Those are precisely the components whose compromise is not an availability incident but a supply chain event, because every artefact they produced afterwards stays trusted downstream until somebody proves otherwise.
What to actually do this week
- Patch to 16.0.4 or 15.0.8. Both branches carry both security fixes. Everything at 16.0.3 and below is affected, so the exposure window is however long your instance has been running, not the four weeks since the last release.
- Establish who can publish a template repository on your instance. If template creation is open to all users, or your instance is public or multi-tenant, this is an untrusted input path. Look at host processes and repository hook directories, not just access logs, because a successful exploit here does not look like an authentication anomaly.
- Inventory your fine-grained tokens. For each one, ask whether the restriction you configured is enforced on every route that reaches the resource, or only on the route you tested when you created it. Most teams have never checked the difference.
- Put every self-hosted service that is not in a lockfile on a named list with a named owner and a subscription to its release feed. Forge, runner, registry, identity provider, secrets manager. If the honest answer to “who reads the Forgejo release notes” is nobody, you have found your gap without needing an audit.
- Audit by object, not by endpoint. Pick your three most sensitive object types. List every code path that can return one, including web routes, API routes, webhooks, export jobs and download URLs. Then confirm the control sits on all of them. Every draft-release bug in history is an object with more exits than its author remembered.
The caller list keeps growing, and that is why this is accelerating
Special-case permission logic fails when a new class of caller appears that the original author never enumerated. A few years ago that class was API tokens, which is exactly what broke Forgejo’s maintainer-edit control. Today it is AI coding agents, issued fine-grained tokens at a volume no human access review was designed to absorb, and reaching your forge through the API rather than the UI. The API is where those older special cases are thinnest, because they were written when the UI was the only way in.
Every agent you hand repository access to is a live test of whether your permission model was written against a list of callers or against the objects themselves. One of those ages well.
REPTILEHAUS builds and audits this layer: self-hosted developer infrastructure, CI/CD and artefact pipelines, and the token and identity plumbing that AI agents now run on. If you are not certain who is reading your forge’s release feed, or how many of your scoped tokens are enforced on only half their routes, get in touch.
📷 Photo by Albert Stoynov on Unsplash
