Research/forgejo-restore-file-open
0-dayNo CVEHighPublic

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

Go
*asset.DownloadURL = "file://" + filepath.Join(r.baseDir, *asset.DownloadURL)
Go
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.

Plain text
git clone https://github.com/abraxas/forgejo-restore-file-open
cd forgejo-restore-file-open
python3 forgejo-restore-file-open-Abraxas-Labs.py

Last lab run, trimmed:

Plain text
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-WITNESS

I 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

Forgejo, restore-repo file:// os.Open · Abraxas Labs