Summary
Gitea's restore-repo CLI command restores a repository from a dump
directory/archive. When parsing pull_request.yml from that dump, the
Head.CloneURL field is used to add a git remote and fetch from it with
no validation, because the safety check that's supposed to guard it
(CheckAndEnsureSafePR) is called with an empty commonCloneBaseURL,
which silently disables it. This lets a malicious dump make the Gitea
server execute git fetch against an attacker-chosen URL (SSRF), or
disclose a local git repository via file://. This is a different root
cause from the recently fixed path-traversal issue in the same command
(#38215), which patched DownloadURL/PatchURL but not Head.CloneURL.
Details
services/migrations/restore.go's GetPullRequests() unmarshals
pull_request.yml directly into base.PullRequest structs with no
validation of Head.CloneURL:
err = yaml.Unmarshal(bs, &pulls)
...
for _, pr := range pulls {
if pr.PatchURL != "" {
pr.PatchURL = "file://" + util.FilePathJoinAbs(r.baseDir, pr.PatchURL)
}
CheckAndEnsureSafePR(pr, "", r) // <-- empty baseURL
}
CheckAndEnsureSafePR (services/migrations/common.go) is supposed to
reject Head.CloneURL/PatchURL values that don't share a common base
URL:
func hasBaseURL(toCheck, baseURL string) bool {
if len(baseURL) > 0 && baseURL[len(baseURL)-1] != '/' {
baseURL += "/"
}
return strings.HasPrefix(toCheck, baseURL)
}
func CheckAndEnsureSafePR(pr *base.PullRequest, commonCloneBaseURL string, g base.Downloader) bool {
valid := true
if pr.PatchURL != "" && !hasBaseURL(pr.PatchURL, commonCloneBaseURL) {
pr.PatchURL = ""
valid = false
}
if pr.Head.CloneURL != "" && !hasBaseURL(pr.Head.CloneURL, commonCloneBaseURL) {
pr.Head.CloneURL = ""
valid = false
}
return valid
}
strings.HasPrefix(anything, "") is always true in Go. Because
restore.go is the only caller that passes "" as
commonCloneBaseURL, this check is a complete no-op on the restore-repo
path — Head.CloneURL survives unchanged regardless of its value. Every
other downloader (github.go, gitlab.go, gitea_downloader.go,
codebase.go, codecommit.go, onedev.go) passes a real base URL, so
they are not affected.
services/migrations/gitea_uploader.go then uses the unvalidated value
directly:
err := g.gitRepo.AddRemote(remote, pr.Head.CloneURL, true)
// ... later: fetch from that remote
resulting in the server executing git fetch against an
attacker-controlled URL sourced from the dump file.
RCE via git's ext:: transport helper was tested and ruled out — a
normal git install rejects it by default (fatal: transport 'ext' not allowed), independent of Gitea's own configuration. This report is
scoped to SSRF and local git-repository disclosure.
Confirmed present, byte-for-byte identical, in v1.26.4 (latest stable
tag), release/v1.27, and main, by direct checkout and diff.
PoC
- Create a dump directory following the normal
restore-repo layout
(repo.yml, etc.), and add a pull_request.yml containing at least
one entry with:
- number: 1
head:
cloneURL: "http://<attacker-controlled-or-internal-host>:<port>/ssrf-proof"
ref: "main"
- Run
gitea restore-repo against that dump directory for any repo
owner.
- Observe on the target host/listener: an actual
git HTTP
discovery request arrives, e.g.
GET /ssrf-proof/info/refs?service=git-upload-pack, driven entirely
by the value from the dump file.
I verified the core mechanism (steps 2–3, i.e. the unvalidated
Head.CloneURL surviving CheckAndEnsureSafePR("") and then being used
in a real git remote add + git fetch) with a minimal, standalone Go
program built from the verbatim, unmodified hasBaseURL /
CheckAndEnsureSafePR function bodies (attached: gitea_ssrf_poc.go),
run end-to-end against a local HTTP listener. The listener's access log
confirms the request actually arrives. I was not able to compile and run
the full gitea binary / actual CLI end-to-end in my environment due to
network restrictions on the Go module proxy; the attached PoC exercises
the exact vulnerable code path rather than a reimplementation, but a
full CLI-level reproduction on your end may still be useful for triage.
Impact
An attacker who can get an administrator to run gitea restore-repo
against a malicious dump (the same threat model already accepted for the
just-fixed path-traversal issue in this command, #38215) can make the
Gitea server issue a git fetch against an arbitrary attacker-chosen
URL. This allows:
- SSRF against internal-only services or cloud metadata endpoints
reachable from the Gitea host.
- Disclosure of local git repositories reachable via
file:// paths
readable by the Gitea process.
No public disclosure planned. Happy to provide further detail on
request.
Summary
Gitea's
restore-repoCLI command restores a repository from a dumpdirectory/archive. When parsing
pull_request.ymlfrom that dump, theHead.CloneURLfield is used to add a git remote and fetch from it withno validation, because the safety check that's supposed to guard it
(
CheckAndEnsureSafePR) is called with an emptycommonCloneBaseURL,which silently disables it. This lets a malicious dump make the Gitea
server execute
git fetchagainst an attacker-chosen URL (SSRF), ordisclose a local git repository via
file://. This is a different rootcause from the recently fixed path-traversal issue in the same command
(#38215), which patched
DownloadURL/PatchURLbut notHead.CloneURL.Details
services/migrations/restore.go'sGetPullRequests()unmarshalspull_request.ymldirectly intobase.PullRequeststructs with novalidation of
Head.CloneURL:CheckAndEnsureSafePR(services/migrations/common.go) is supposed toreject
Head.CloneURL/PatchURLvalues that don't share a common baseURL:
strings.HasPrefix(anything, "")is alwaystruein Go. Becauserestore.gois the only caller that passes""ascommonCloneBaseURL, this check is a complete no-op on the restore-repopath —
Head.CloneURLsurvives unchanged regardless of its value. Everyother downloader (
github.go,gitlab.go,gitea_downloader.go,codebase.go,codecommit.go,onedev.go) passes a real base URL, sothey are not affected.
services/migrations/gitea_uploader.gothen uses the unvalidated valuedirectly:
resulting in the server executing
git fetchagainst anattacker-controlled URL sourced from the dump file.
RCE via git's
ext::transport helper was tested and ruled out — anormal
gitinstall rejects it by default (fatal: transport 'ext' not allowed), independent of Gitea's own configuration. This report isscoped to SSRF and local git-repository disclosure.
Confirmed present, byte-for-byte identical, in
v1.26.4(latest stabletag),
release/v1.27, andmain, by direct checkout and diff.PoC
restore-repolayout(
repo.yml, etc.), and add apull_request.ymlcontaining at leastone entry with:
gitea restore-repoagainst that dump directory for any repoowner.
gitHTTPdiscovery request arrives, e.g.
GET /ssrf-proof/info/refs?service=git-upload-pack, driven entirelyby the value from the dump file.
I verified the core mechanism (steps 2–3, i.e. the unvalidated
Head.CloneURLsurvivingCheckAndEnsureSafePR("")and then being usedin a real
git remote add+git fetch) with a minimal, standalone Goprogram built from the verbatim, unmodified
hasBaseURL/CheckAndEnsureSafePRfunction bodies (attached:gitea_ssrf_poc.go),run end-to-end against a local HTTP listener. The listener's access log
confirms the request actually arrives. I was not able to compile and run
the full
giteabinary / actual CLI end-to-end in my environment due tonetwork restrictions on the Go module proxy; the attached PoC exercises
the exact vulnerable code path rather than a reimplementation, but a
full CLI-level reproduction on your end may still be useful for triage.
Impact
An attacker who can get an administrator to run
gitea restore-repoagainst a malicious dump (the same threat model already accepted for the
just-fixed path-traversal issue in this command, #38215) can make the
Gitea server issue a
git fetchagainst an arbitrary attacker-chosenURL. This allows:
reachable from the Gitea host.
file://pathsreadable by the Gitea process.
No public disclosure planned. Happy to provide further detail on
request.