Gitea, authenticated git HTTP redirect SSRF
Gitea 1.27.3 IsMigrateURLAllowed is a one-shot DNS check. Git clone still follows HTTP 302 because HandleGitCmdHTTPRedirection is a no-op. A signed-in user migrates from an allow-listed bait into loopback git HTTP. No CVE yet.
- Name
- Gitea, authenticated git HTTP redirect SSRF
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Gitea
- Affected
- Gitea through v1.27.3 (146cc3e); unpublished leftover of CVE-2026-59765 and CVE-2026-22874. Needs a user who can migrate or add a pull mirror. Default open registration.
- Published
- 12 Oct 2026
- Updated
- 12 Oct 2026
- Tags
- 0-day, gitea, ssrf, git, migrate, cwe-918, authenticated
The FIXME is the finding
I am @abraxas_null. The proof of concept is on GitHub: abraxas/gitea-git-redir-ssrf (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, redir.py, internal-git.sh. Authorized lab only. It talks to loopback.
This is Gitea v1.27.3 (146cc3e). No CVE yet. CWE-918. 7.7 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N). Reach: Remote. Class: SSRF. Authenticated. Default open registration plus repo-create. ALLOW_LOCALNETWORKS=false.
IsMigrateURLAllowed is a one-shot net.LookupIP on the URL you typed. Bytes then go through git clone --mirror. HandleGitCmdHTTPRedirection is supposed to stop git from following that hop. It does nothing. The comment is the leftover: they left http.followRedirects=false off because GitLab 301s without .git. Pull-mirror fetch calls the same no-op.
A signed-in user migrates from an allow-listed bait host. Git follows the 302 into loopback git HTTP. Internal objects land in a repo they own. Direct migrate to 127.0.0.1 is denied. Redirect-followed clone is not.
Same tag leftovers: keys IDOR, hostmatcher 0.0.0.0/8.
Why Gitea
I had just finished mapping the 2026 GHSA wave onto this tree. Diffpatch hook RCE is in CISA KEV. Org-mode #+INCLUDE was unauthenticated file read into RCE. The Go HTTP client for migrate finally grew a hostmatcher. The reserved-IP list finally grew CGNAT and cloud metadata. That is the kind of winter where you expect the git binary to have gotten the memo too.
Gitea is the self-hosted git forge people actually run when they do not want GitLab's footprint. Default registration is open. Migrate is a feature: paste a URL, own a copy. The security model for that URL is "we looked it up once." Git's security model for HTTP is "I follow 302s unless you tell me not to." Those two sentences do not compose.
I only wanted Critical or High. Remote and local. Medium stays on the floor unless it chains. Forgejo is a different tree. GitLab importer SSRF is a different product. This pin is go-gitea/gitea tag v1.27.3.
Pinning 1.27.3
Latest stable on the day I cloned. Tag object v1.27.3, commit 146cc3e. Image gitea/gitea:1.27.3. Stay on 1.27.3+ for the disclosed GHSAs. Do not set REVERSE_PROXY_TRUSTED_PROXIES = *.
I sat on that tag anyway. Patches that add an allow-list on the Go dialer are a gift: you read the list, then you ask which other HTTP client still talks to the network. Git is the other HTTP client.
The skip list is half the hunt
Before you look for leftovers, you write down what is already public so you do not "discover" a CVE with a press release. On 1.27.3 the interesting ones were already closed:
- CVE-2026-60004
diffpatchhook RCE. Clone is non-bare. CISA KEV. RotateINTERNAL_TOKEN, JWT, DB password, and runner tokens if the instance was ever ≤ 1.27.0. - CVE-2026-59774 unauth Org-mode
#+INCLUDE.ReadFileis a stub. - CVE-2026-20896 Docker reverse-proxy
*. Template no longer hard-codes it. - CVE-2026-22874 Go
reservedIPNets. CGNAT, TEST-NET, NAT64, Teredo, Azure WireServer. The Go transport. Not git. The missing0.0.0.0/8is a sibling. - CVE-2026-59765 Go
uri.Openhostmatcher. Live migrate HTTP is gated. Git clone is noturi.Open. - CVE-2026-66877 / CVE-2026-71184 fork-PR Actions approval. Closed on this tag.
- CVE-2026-28699 / CVE-2026-28744 OAuth Basic and Smart HTTP Bearer scopes. Closed.
- Changelog line "disable HTTP redirects on pull mirror sync." That is not this function.
HandleGitCmdHTTPRedirectionis still empty.
I am not re-filing the Go-transport GHSAs.
Surfaces that returned nothing
I split the tree: webhook deliver, migrate HTTP, git clone, pull-mirror fetch, restore CLI, file://, internal API, hostmatcher CIDRs, Actions artifacts.
Most of that is Gitea doing what the 1.27.3 changelog said it would do, or a different leftover with its own lab.
Direct POST /api/v1/repos/migrate to http://127.0.0.1:... is 422 "You can not import from disallowed hosts" when ALLOW_LOCALNETWORKS is false. That is the control. If it succeeds, the lab is contaminated.
file:// is not a git HTTP URL. Git will not set X-Gitea-Internal-Auth. /api/internal stays closed on this chain. Live migrate sets DownloadFunc on release assets, so restore's os.Open is not this bug. FilePathJoinAbs dump-root escape FAILs on this tag. Webhook 0.0.0.1 is a different SSRF: full HTTP body in hook history, Go transport, missing /8. Diffpatch is non-bare. Org include is stubbed.
The temperature changed on a helper whose body is a comment.
Git follows the 302. The helper does not stop it.
func HandleGitCmdHTTPRedirection(cmd *gitcmd.Command, targets ...string) {
// Protect from SSRF vector (e.g. migrating from an attacker URL).
// cmd.AddConfig("http.followRedirects", "false")
// However, we can't do so at the moment:
// this fails due to 301: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}
// this succeeds: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}.git
// FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future
}It does nothing. GitLab 301s without .git are why they left it off. Clone calls it. CloneExternalRepo is the migrate path. mirror_pull.go calls the same no-op on fetch.
IsMigrateURLAllowed only inspects the first hostname. addrList, _ := net.LookupIP even ignores lookup errors. Git then follows redirects with Git's own resolver.
I am not going to print a migrate JSON you can paste at someone else's /api/v1/repos/migrate. The empty helper is the useful part.
The lab
Source of truth is lab/. Same image pin. Port 18103. ALLOW_LOCALNETWORKS=false.
Gitea sits on 8.8.4.2 so a bait host at 8.8.4.10 looks external to hostmatcher. Internal git HTTP binds loopback only on the Gitea netns (network_mode: service:gitea, port 9418). Direct migrate to 127.0.0.1 must fail. Redirect-followed clone must succeed. redir.py 302s /bait.git to http://127.0.0.1:9418/internal.git. Witness file WITNESS contains GHSA-GITEA-GIT-REDIR-SSRF.
# Loopback lab image pin for gitea-git-redir-ssrf. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3name: gitea-git-redir-ssrf
networks:
labnet:
ipam:
config:
- subnet: 8.8.4.0/24
services:
gitea:
image: gitea/gitea:1.27.3
ports:
- "127.0.0.1:18103:3000"
networks:
labnet:
ipv4_address: 8.8.4.2
environment:
USER_UID: "1000"
USER_GID: "1000"
GITEA__database__DB_TYPE: sqlite3
GITEA__database__PATH: /data/gitea/gitea.db
GITEA__security__INSTALL_LOCK: "true"
GITEA__server__DOMAIN: 127.0.0.1
GITEA__server__HTTP_PORT: "3000"
GITEA__server__ROOT_URL: http://127.0.0.1:18103/
GITEA__service__DISABLE_REGISTRATION: "false"
GITEA__migrations__ALLOW_LOCALNETWORKS: "false"
volumes:
- gitea_data:/data
gitinternal:
image: python:3.12-alpine
network_mode: service:gitea
depends_on:
- gitea
volumes:
- ./internal-git.sh:/internal-git.sh:ro
command: ["sh", "/internal-git.sh"]
redirector:
image: python:3.12-alpine
networks:
labnet:
ipv4_address: 8.8.4.10
volumes:
- ./redir.py:/redir.py:ro
command: ["python3", "/redir.py"]
depends_on:
- gitinternal
volumes:
gitea_data:internal-git.sh inits a bare repo with the witness file and serves it with python3 -m http.server 9418 --bind 127.0.0.1. Nothing on the compose network can hit that port except a process already in Gitea's netns. Git, after the 302, is that process.
git clone https://github.com/abraxas/gitea-git-redir-ssrf
cd gitea-git-redir-ssrf/lab
./run.shThe loopback client is lab/run.sh with a banner on it. Target only http://127.0.0.1:18103. Do not publish the port off loopback.
Last lab run, trimmed:
negative-loopback status=422 You can not import from disallowed hosts
migrate-bait status=201 clone_addr=http://8.8.4.10/bait.git
fetch raw/WITNESS status=200 GHSA-GITEA-GIT-REDIR-SSRF
SUCCESS Gitea #2 git HTTP redirect SSRFDirect loopback migrate denied. Redirect-followed migrate contained the internal file. That is the whole argument.
Wrong turns, in the order I made them
ALLOW_LOCALNETWORKS=true. Then the allow-list is gone and direct loopback migrate succeeds. That is a config flag, not this leftover. The lab sets it false. If the negative 422 is missing, FAIL.
file:// as the migrate URL. Git speaks HTTP. This chain is not disk read. Restore file:// os.Open is a sibling.
Hitting /api/internal with X-Gitea-Internal-Auth. Git will not set that header. Internal listener is usually loopback or unix. This leftover does not become Gitea RCE by wishing.
Treating webhook create as the oracle. Webhook history is a better SSRF oracle (full HTTP body). That is the 0.0.0.0/8 leftover. This lab's oracle is a file in a repo the attacker owns.
Claiming the changelog already fixed it. "Disable HTTP redirects on pull mirror sync" is not HandleGitCmdHTTPRedirection. The function is still a FIXME.
DNS rebinding as SUCCESS. IsMigrateURLAllowed ignores LookupIP errors and only sees the first hostname. Rebind is a fair inferred chain. The lab oracle is a 302 to loopback git HTTP, not a TOCTOU on A records.
Blind port scan as SUCCESS. Non-git Location usually makes clone fail. Git stderr in clone error can still be a port oracle. Inferred. Lab used a real git HTTP target.
Metadata RCE. Cloud metadata as git Smart HTTP is unusual. I did not lab it. Do not file this as Gitea RCE.
A reverse shell. Theatre. The witness is GHSA-GITEA-GIT-REDIR-SSRF in raw/WITNESS.
Client bugs that look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like 1.27.3 had finished the job.
What an attacker can do
Register, migrate from an external-looking URL that 302s to http://127.0.0.1:.../internal.git. Direct migrate to loopback is denied. Redirect-followed clone is not. Witness file lands in their repo.
| Privilege | Method + path / sink | Kind | Lab-proven or inferred |
|---|---|---|---|
| Clone an internal git HTTP repo via a public 302 | POST /api/v1/repos/migrate with clone_addr on an allow-listed bait that 302s to loopback git HTTP |
Recon (changed scope: Gitea host fetches, attacker reads) | Lab-proven. Bait http://8.8.4.10/bait.git 201; raw/WITNESS is GHSA-GITEA-GIT-REDIR-SSRF |
| Same hop on later pull-mirror fetch | mirror_pull.go calls the same no-op |
Recon | Inferred from the caller. Lab used migrate, not a live mirror update |
| Blind port / error oracle if Location is not git Smart HTTP | git stderr in clone error |
Recon | Inferred. Lab used a real git HTTP target |
| DNS rebind / TOCTOU on the first hostname | IsMigrateURLAllowed one-shot LookupIP, ignores errors |
Recon | Inferred. Not the lab oracle |
| Follow-on secrets that happen to sit in the stolen repo | Objects land in a repo the attacker owns | Recon | Inferred from git contents. Lab witness is a planted file, not app.ini |
file:// disk read of app.ini |
git speaks HTTP, not file:// |
Recon | Lab-proven closed for this chain |
Call /api/internal with X-Gitea-Internal-Auth |
git will not set that header | Integrity | Closed. Not this leftover |
| Unauthenticated RCE / OS LPE | needs a migrate-capable user; same uid as gitea | Integrity / availability | Closed |
Direct migrate to 127.0.0.1 with ALLOW_LOCALNETWORKS=false |
POST /api/v1/repos/migrate |
Recon | Lab-proven closed. 422 "You can not import from disallowed hosts" |
No integrity of Gitea's own process. No availability. Confidentiality of whatever git HTTP is reachable from the Gitea host after a 302: High, changed scope. That is why it is in bar.
What you steal is an internal git repo: source, history, secrets someone committed, deploy keys in files. You browse it on a repo you own. You do not get a shell on the Gitea host. If those secrets are cloud keys or a checked-in app.ini, that is follow-on credential theft in the stolen tree, not Gitea executing code.
What this is not
It is not "CVE-2026-59765 never shipped." Go uri.Open is gated. Git is not uri.Open. It is not "CVE-2026-22874 never shipped." The reserved list is real on the Go dialer. Git's redirector is still a FIXME.
It is not file:// disk read. It is not X-Gitea-Internal-Auth on /api/internal. It is not unauthenticated RCE. It is not OS LPE. It is not metadata RCE unless you separately show git stored the body.
If the instance was ever ≤ 1.27.0, rotate INTERNAL_TOKEN, JWT, DB password, and runner tokens anyway. That is the disclosed diffpatch / Org-include incident, not this hop.
The fix
Do not treat submit-time DNS as the boundary. Force http.followRedirects=false (and normalize clone URLs to .git so GitLab still works), or proxy git HTTP through a dialer that re-runs hostmatcher on every hop.
Re-run the loopback client against a patched build: bait migrate must fail the same way direct loopback migrate already does.
Until then, do not give migrate or pull-mirror to anyone you do not already trust with every git HTTP service the Gitea host can reach after a 302.
I am not going to print a migrate JSON you can paste at someone else's instance. The empty redirect helper is the useful part. If you own the box, run the proof of concept against loopback.
Same product, other unpublished labs on this tag: keys IDOR · hostmatcher 0.0.0.0/8 · public-only PAT org · stargazers hidden · follow existence · artifact v4 ReadAll · restore file://.
References
- Proof of concept: abraxas/gitea-git-redir-ssrf · gitea-git-redir-ssrf-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py · redir.py · internal-git.sh - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-918
- Tree: gitea v1.27.3 (
146cc3e) ·HandleGitCmdHTTPRedirection·IsMigrateURLAllowed - Nearby patched: CVE-2026-59765 · CVE-2026-22874
- Same tag: gitea-user-keys-idor
- SECURITY.md
- Product: Gitea