Gitea 1.27.3, unpublished IDOR and git-redirect SSRF
v1.27.3 closed the 2026 GHSA wave. Two unpublished bugs still lab on that tag: GET /api/v1/user/keys/{id} has no owner check, and git clone still follows HTTP redirects after a one-shot allow-list. Not unauthenticated RCE. CVE pending.
- Name
- Gitea 1.27.3, unpublished IDOR and git-redirect SSRF
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Gitea
- Affected
- Gitea v1.27.3 (146cc3e) and likely earlier; unpublished. Stay on 1.27.3+ for the disclosed GHSAs; these two are extra.
- Published
- 26 Sept 2026
- Updated
- 26 Sept 2026
- Tags
- 0-day, gitea, idor, ssrf, cwe-639, cwe-918, authenticated
The GHSA wave, then two leftovers
I am @abraxas_null. Two loopback labs, one source pass, Gitea tag v1.27.3 (146cc3e). Isolated. I did not point this at the internet.
v1.27.3 is the build that closed CVE-2026-60004 (diffpatch hook RCE, CISA KEV), CVE-2026-59774 (unauth Org-mode #+INCLUDE), the fork-PR Actions gates, the reverse-proxy X-WEBAUTH-USER default, and a pile of scope/visibility bugs. Stay on 1.27.3+ for those. Do not set REVERSE_PROXY_TRUSTED_PROXIES = *.
This post is not that list. This is what still labbed on 1.27.3:
- SSH / deploy-key IDOR -
GET /api/v1/user/keys/{id}loads by sequential ID with no owner check. Lab: abraxas/gitea-user-keys-idor. - Git HTTP redirect SSRF - migrate allow-list is a one-shot DNS check;
HandleGitCmdHTTPRedirectionis a no-op FIXME. Git follows302to loopback git HTTP. Lab: abraxas/gitea-git-redir-ssrf.
Both need a signed-in user (default open registration). Neither is unauthenticated RCE. Neither is OS LPE. The IDOR inventories keys. The SSRF clones an internal git repo into a repo you own. Coordinated disclosure with Gitea; no CVE numbers yet.
I am not printing a key dump or a migrate JSON you can paste at someone else's instance. The owner check, the empty redirect helper, and the loopback labs are the useful part.
Lab 1: other people's keys
Source of truth: lab/. Image gitea/gitea:1.27.3. Port 18101.
# Loopback lab image pin for gitea-user-keys-idor. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3name: gitea-user-keys-idor
services:
gitea:
image: gitea/gitea:1.27.3
ports:
- "127.0.0.1:18101:3000"
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:18101/
GITEA__service__DISABLE_REGISTRATION: "false"
GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
volumes:
- gitea_data:/data
volumes:
gitea_data:GET /api/v1/user/keys/{id} sits in the current-user group (reqToken, rejectPublicOnly). The handler still loads the row by numeric ID with no owner check. The same table holds user keys, deploy keys, and principals. GPG's sibling does it right: GetGPGKeyForUserByID(ctx, ctx.Doer.ID, id).
key, err := asymkey_model.GetPublicKeyByID(ctx, ctx.PathParamInt64("id"))
// ...
apiKey := convert.ToPublicKey(apiLink, key)
if ctx.Doer.IsAdmin || ctx.Doer.ID == key.OwnerID {
apiKey, _ = appendPrivateInformation(ctx, apiKey, key, ctx.Doer)
}
ctx.JSON(http.StatusOK, apiKey)Private fields are gated. The public key material, title, and fingerprint are not. IDs are sequential. Walk 1, 2, 3. A read:user token is enough. Open registration makes that a new-account bug.
The 1.27.3 key GHSA was username-scoped list plus individualPermsChecker. It was not this lookup.
Bring-up: run.sh then seed.sh plants victim key title GHSA-GITEA-KEY-IDOR-WITNESS and an attacker key. Then gitea-user-keys-idor-Abraxas-Labs.py.
git clone https://github.com/abraxas/gitea-user-keys-idor
cd gitea-user-keys-idor/lab
./run.shLast lab run, trimmed:
add-key user=victim status=201 title=GHSA-GITEA-KEY-IDOR-WITNESS id=1
add-key user=attacker status=201 id=2
attacker-list status=200 ids=[2] (no witness)
get id=1 status=200 snippet contains GHSA-GITEA-KEY-IDOR-WITNESS
SUCCESS Gitea #1 user/keys IDORAttacker's own list does not contain the witness. GET /user/keys/1 as the attacker does. GPG with a foreign id 404s. That is the whole argument. I am not reprinting the key.
Fix: 404 unless key.OwnerID == ctx.Doer.ID (or site admin). Reject deploy/principal types. Match GPG.
Lab 2: git follows the 302
Source of truth: lab/. Same image pin. Port 18103. ALLOW_LOCALNETWORKS=false.
# Loopback lab image pin for gitea-git-redir-ssrf. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3docker-compose.yml puts Gitea 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.
name: 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:IsMigrateURLAllowed is a one-shot net.LookupIP on the URL you typed. Bytes then go through git clone --mirror. This function is supposed to stop git from following that hop:
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. The comment is the finding. GitLab 301s without .git are why they left it off. Pull-mirror fetch calls the same no-op.
CVE-2026-59765 fixed Go uri.Open. CVE-2026-22874 fixed Go reservedIPNets. Git's redirector is still a FIXME. Changelog "disable HTTP redirects on pull mirror sync" is not this function.
What it is: authenticated SSRF that closes as an internal git clone. Objects land in a repo the attacker owns. Witness file WITNESS contains GHSA-GITEA-GIT-REDIR-SSRF.
What it is not: file:// disk read. X-Gitea-Internal-Auth on /api/internal (git will not set that header). Unauthenticated RCE. Metadata RCE unless you separately show git stored the body.
Bring-up: run.sh. Bait is redir.py (302 /bait.git to http://127.0.0.1:9418/internal.git). Internal repo from internal-git.sh. Then gitea-git-redir-ssrf-Abraxas-Labs.py.
git clone https://github.com/abraxas/gitea-git-redir-ssrf
cd gitea-git-redir-ssrf/lab
./run.shLast 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.
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.
Same-tree leftovers, now labbed
Those later got their own labs. One post each:
| Lab | Impact | Post |
|---|---|---|
Webhook to http://0.0.0.1/ |
Authenticated full-response SSRF | hostmatcher 0.0.0.0/8 |
| Public-only PAT creates private org | Authz bypass | public-only PAT org |
| Stargazers / watchers | Unauth username leak on a public repo | stargazers hidden |
| Follow 204 vs 404 | Hidden-user existence oracle | follow existence |
Artifact v4 io.ReadAll |
Job-token DoS | artifact v4 ReadAll |
restore file:// os.Open |
Local LFI as the Gitea uid | restore file:// |
FilePathJoinAbs dump-root escape still did not land on 1.27.3. Live migrate still sets DownloadFunc.
What this is not
It is not "Gitea 1.27.3 is still KEV RCE." Diffpatch clone is non-bare. Org ReadFile is stubbed. It is not unauthenticated RCE, not OS LPE, not a reverse shell.
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 these two.
I am not going to print a migrate JSON you can paste at someone else's /api/v1/repos/migrate, or a walk of /user/keys/1..N aimed at a live host. The owner check and the empty redirect helper are the useful part. If you own the box, run the two loopback clients.
Client bugs 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.
References
- Labs: gitea-user-keys-idor · gitea-user-keys-idor-Abraxas-Labs.py · gitea-git-redir-ssrf · gitea-git-redir-ssrf-Abraxas-Labs.py
- Lab trees: keys
lab/· ssrflab/ - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- Tree: gitea v1.27.3 ·
GetPublicKey·HandleGitCmdHTTPRedirection - CWE-639 · CWE-918
- Nearby patched: CVE-2026-60004 · CVE-2026-59774 · CVE-2026-59765 · CVE-2026-22874