Forgejo, restore-repo file:// os.Open
Forgejo 16.0.5 restore-repo rewrites release asset URLs with raw filepath.Join and prefixes file://. uri.Open still os.Open's that path. Relative .. walks out of the dump. Not live-migrate LFI. No CVE yet.
- Name
- Forgejo, restore-repo file:// os.Open
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Forgejo
- Affected
- Forgejo through v16.0.5 (8d24b7d); unpublished. Local operator forgejo restore-repo on an untrusted dump. Absolute DownloadURL dump-root escape does not land. Gitea localFileURL is not in this tree.
- Published
- 12 Oct 2026
- Updated
- 12 Oct 2026
- Tags
- 0-day, forgejo, lfi, restore, cwe-22, cwe-73, authenticated
file:// still calls os.Open
I am @abraxas_null. The proof of concept is on GitHub: abraxas/forgejo-restore-file-open (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is Forgejo v16.0.5 (8d24b7d52df327118cd776cf461d34e52e0ec1ce). No CVE yet. CWE-22 / CWE-73. 7.3 High (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N). Local file read. Operator restore.
forgejo restore-repo of an untrusted dump rewrites every release asset download_url with filepath.Join(baseDir, value) and prefixes file://. uri.Open still has a file branch that os.Opens that path. There is no containment under the dump. Gitea's localFileURL helper is not in this tree.
Why Forgejo
I had two restore ideas on Gitea 1.27.3. Absolute join failed. Dump-side symlink succeeded. Forgejo never grew OpenWithClient or localFileURL. Raw filepath.Join plus file:// is still here. Independently re-checked.
I only wanted Critical or High. Local is in-scope.
Pinning v16.0.5
Latest GA: v16.0.5, commit 8d24b7d. Image codeberg.org/forgejo/forgejo:16.0.5.
The skip list is half the hunt
Closed on this pin: CVE-2026-89094 .git recreate, ApplyDiffPatch RCE, git HTTP redirect SSRF, CGNAT, draft attachments, OpenID association CSRF, org-mode host file read, Actions pwn-request. Instance dump always packs app.ini. That is secrets by design, archive mode 0600. Do not treat "backup contains secrets" as a vuln.
Live POST /repos/migrate sets DownloadFunc. That path does not os.Open an attacker file://. PR PatchURL is prefix-checked against the clone HTTP base. file:// clone is only doer.CanImportLocal(). This leftover is restore of a dump, not remote LFI.
Gitea 1.27.3 absolute DownloadURL join-escape FAILed. Go filepath.Join does not drop an absolute second argument the way Python os.path.join does. Join("/dump", "/etc/passwd") stays /dump/etc/passwd. FilePathJoinAbs is in-tree and is not used by restore. Same FAIL here. The working input is relative ...
Twenty surfaces, one restore join
Templates, git-apply, Actions, packages, migrate, hooks, tokens, federation, OpenID, attachments, restricted users, hostmatcher, LFS, keys, restore, OAuth, markup, internal API, secrets, visibility.
Restore of a dump has no DownloadFunc. GiteaLocalUploader.CreateReleases then opens the rewritten file:// URL. Empty --units restores releases.
The join that walks out
*asset.DownloadURL = "file://" + filepath.Join(r.baseDir, *asset.DownloadURL)case "file":
return os.Open(u.Path)NewRepositoryRestorer takes filepath.Abs(baseDir) only. No containment. No EvalSymlinks. Clean on a relative .. walks out of the dump. os.Open follows a dump-side symlink if you plant one instead.
A YAML download_url that is already file://... is not the working path: join treats it as a relative name under the dump. Pre-rewrite https://... also becomes file:// + join, so restore is not HTTP SSRF.
CLI posts RepoDir to internal POST /api/internal/restore_repo. That is the operator path, not unauth HTTP restore.
Wrong turns, in the order I made them
Absolute DownloadURL dump-root escape. Go filepath.Join keeps the dump prefix. FAIL on Gitea 1.27.3. FAIL here.
Live migrate file:// LFI. DownloadFunc is set. Not this sink.
X-Forgejo-Internal-Auth. Restore is CLI as the forgejo uid, not a stolen internal token.
Treating restore HTTP as remote unauth. CVSS is local, user interaction. The operator has to run restore-repo.
A reverse shell. Theatre. The witness is a planted file next to the dump.
What an attacker can do
Hand the operator a dump directory (or already have INTERNAL_TOKEN and a path the server can read). Units include releases / release_assets.
| Privilege | Method / sink | Recon / integrity / availability | Lab-proven or inferred |
|---|---|---|---|
Read a host file the forgejo uid can open, via .. out of the dump |
release.yml download_url: ../FORGEJO-FILE-WITNESS.txt; restore joins, uri.Open os.Opens, bytes stored as witness.txt |
Recon (host-file confidentiality) | Lab-proven. Attachment body is FORGEJO-RESTORE-FILE-OPEN-WITNESS. In-dump decoy INSIDE-DUMP-NOT-HOST was not served |
| Same result with a symlink planted inside the dump | Joined path stays under baseDir; os.Open follows the link |
Recon | Inferred from uri.Open file -> os.Open. Lab used .., not a symlink |
| Same sink on PR patches | pull_request.yml PatchURL is rewritten the same way, then uri.Open into repo/pulls/<n>.patch |
Recon | Inferred from restore.go GetPullRequests. Lab restored releases only |
| Readers of the restored repo download the host file | GET /<owner>/<repo>/releases/download/<tag>/<name> |
Recon | Lab-proven as labadmin Basic on the restored public repo |
Typical host files: app.ini, INTERNAL_TOKEN, SECRET_KEY, DB password |
forgejo uid can read the config tree | Recon | Inferred. Lab witness was a planted file next to the dump, not live secrets |
Closed here: live POST /repos/migrate file:// LFI. Absolute DownloadURL dump-root join escape. Unauth HTTP restore. Backup dump packing app.ini. Not OS LPE.
The lab
Source of truth is lab/. Image codeberg.org/forgejo/forgejo:16.0.5. Host bind 127.0.0.1:19340. Dump sat at /data/restore-dump. download_url was ../FORGEJO-FILE-WITNESS.txt. A decoy inside the dump did not match. Restore returned 0. GET /labadmin/restored/releases/download/v1.0/witness.txt was the host witness.
git clone https://github.com/abraxas/forgejo-restore-file-open
cd forgejo-restore-file-open
python3 forgejo-restore-file-open-Abraxas-Labs.pyLast lab run, trimmed:
IOC asset name='witness.txt' size=34
IOC download status=200 bytes=34 snippet=b'FORGEJO-RESTORE-FILE-OPEN-WITNESS\n'
SUCCESS forgejo-restore-file-open how=live ..-join file:// os.Open FORGEJO-RESTORE-FILE-OPEN-WITNESSI am not printing a dump YAML you can hand an operator. The file:// -> os.Open branch is the useful part.
The fix
Contain joined paths under baseDir after EvalSymlinks (Gitea PR 38215 localFileURL). Refuse ... Do not follow dump-side symlinks out of the dump. Drop file:// from uri.Open except a dump-rooted helper.
Same tag leftovers: keys IDOR, webhook 0.0.0.1 SSRF, OpenID discovery SSRF.
References
- Proof of concept: abraxas/forgejo-restore-file-open · forgejo-restore-file-open-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-22 · CWE-73
- Tree: forgejo/forgejo v16.0.5 (
8d24b7d52df327118cd776cf461d34e52e0ec1ce) ·restore.go·uri.go - Same class on Gitea: gitea-restore-file-open