Research/Gitea-1.27.3-unpublished
0-dayNo CVEHighPublic

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:

  1. SSH / deploy-key IDOR - GET /api/v1/user/keys/{id} loads by sequential ID with no owner check. Lab: abraxas/gitea-user-keys-idor.
  2. Git HTTP redirect SSRF - migrate allow-list is a one-shot DNS check; HandleGitCmdHTTPRedirection is a no-op FIXME. Git follows 302 to 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.

Dockerfile
# Loopback lab image pin for gitea-user-keys-idor. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3
YAML
name: 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).

Go
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.

Plain text
git clone https://github.com/abraxas/gitea-user-keys-idor
cd gitea-user-keys-idor/lab
./run.sh

Last lab run, trimmed:

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

Attacker'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.

Dockerfile
# Loopback lab image pin for gitea-git-redir-ssrf. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3

docker-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.

YAML
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:

Go
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.

Plain text
git clone https://github.com/abraxas/gitea-git-redir-ssrf
cd gitea-git-redir-ssrf/lab
./run.sh

Last lab run, trimmed:

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

Direct 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

Gitea 1.27.3, unpublished IDOR and git-redirect SSRF · Abraxas Labs