Vulnerability GHSA-2fcr-jfvc-vgg2
Summary
Gitea: Two SSRF findings
Details
| --- | --- | | Versions tested | gitea/gitea:1.26.2 (digest sha256:7d13848af12645600a5f9d93ee2560daa9c6fa6b5b859b7bff3a5e1c0b661031); gitea/gitea:latest resolves to the same digest at time of writing | | Source review | git checkout v1.26.2 (commit 2c749ce) | | Reproduction | bash run_poc.sh (single shot: brings up containers, runs three PoCs, prints captured evidence, tears down on exit) | | Files touched by the fixes | modules/hostmatcher/hostmatcher.go, modules/auth/openid/openid.go |
Summary
Gitea guards outbound HTTP from webhooks and repo migration with net.Dialer.Control, the correct hook point. The IP classifier behind it misses ten address families, of which CGNAT (100.64.0.0/10) is the practically important one because it is plain IPv4 and is used today by Tailscale, AWS VPC secondary CIDRs, and several Kubernetes pod-CIDR conventions. Any logged-in user can create a webhook pointing at an internal CGNAT host. The full HTTP response from that host (status, headers, body up to 1 MB) is stored in the webhook delivery log and rendered to the webhook owner on the hook detail page. The same gap applies to repo migration.
Separately, the OpenID sign-in form at /user/login/openid fetches the user-supplied provider URL server-side via openid-go, which uses http.DefaultClient. No hostmatcher, no IP filter, no CSRF, no authentication. When OpenID sign-in is enabled, anyone on the internet can drive Gitea into making arbitrary GET requests against internal IPs.
Both reproduce on gitea/gitea:1.26.2 (current stable) in default configuration. The bundled run_poc.sh reproduces all three primitives end-to-end in about one minute and tears the lab down at exit.
Finding 2: OpenID discovery has no SSRF guard
The bug
routers/web/auth/openid.go:99:
url, err := openid.RedirectURL(id, redirectTo, setting.AppURL)
The thin wrapper at modules/auth/openid/openid.go:36 forwards directly to the package-level function in github.com/yohcop/openid-go. That package keeps a defaultInstance:
// github.com/yohcop/openid-go v1.0.1, openid.go:15
var defaultInstance = NewOpenID(http.DefaultClient)
http.DefaultClient has no transport customization, no Dialer.Control, no IP filtering. openid-go issues a server-side GET to the user-supplied URL to discover the OpenID endpoint. The fetch reaches any address Gitea can route to, including loopback, RFC 1918, link-local, and the same families listed in Finding 1.
The form does not enforce CSRF on this path. The endpoint accepts unauthenticated requests by design (it is the login page).
Default exposure
The setting that gates this endpoint is read from the [openid] section of app.ini:
// modules/setting/service.go:268-270
func loadOpenIDSetting(rootCfg ConfigProvider) {
sec := rootCfg.Section("openid")
Service.EnableOpenIDSignIn = sec.Key("ENABLE_OPENID_SIGNIN").MustBool(!InstallLock)
It defaults to !InstallLock, so on a fresh container before the install wizard completes the endpoint is enabled. After INSTALL_LOCK=true it defaults off. Deployments that use OpenID for SSO set it explicitly. (Note for anyone reproducing in Docker: the entrypoint routes GITEA__service__ENABLE_OPENID_SIGNIN into [service], where the loader does not read it. Use GITEA__openid__ENABLE_OPENID_SIGNIN=true.)
History
Extends CVE-2021-45325. The 2019 fix (PR #5705) hid the error string that previously leaked internal topology to the requester. It did not add filtering to the discovery fetch itself. The underlying SSRF primitive remains.
Reproduce
One request, no cookie, no token:
curl -X POST http://localhost:3000/user/login/openid \
--data-urlencode 'openid=http://INTERNAL:PORT/path'
Captured by the internal target (also shown in run_poc.sh's [poc 3] block):
GET /poc3-openid from=100.64.0.10
GET /poc3-openid from=100.64.0.10
Two GETs (one for normalize, one for redirect-URL discovery), both unauthenticated, both with Accept: application/xrds+xml.
Exfiltration is blind. openid-go parses the response as XRDS or HTML for endpoint discovery and does not return the body to the caller. Useful for internal port scanning, IMDS probing, and timing oracles. Lower direct impact than Finding 1, but reachable without an account.
Suggested fix
Wire the hostmatcher into a custom *http.Client and create a dedicated openid-go instance:
--- a/modules/auth/openid/openid.go
+++ b/modules/auth/openid/openid.go
@@ -1,10 +1,28 @@
package openid
-import "github.com/yohcop/openid-go"
+import (
+ "net/http"
+ "time"
+
+ "code.gitea.io/gitea/modules/hostmatcher"
+ "code.gitea.io/gitea/modules/proxy"
+ "github.com/yohcop/openid-go"
+)
var (
nonceStore = openid.NewSimpleNonceStore()
discoveryCache = newTimedDiscoveryCache(24 * time.Hour)
+ instance = openid.NewOpenID(&http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &http.Transport{
+ Proxy: proxy.Proxy(),
+ DialContext: hostmatcher.NewDialContext("openid",
+ hostmatcher.ParseHostMatchList("openid", hostmatcher.MatchBuiltinExternal),
+ nil, nil),
+ },
+ })
)
func Verify(fullURL string) (id string, err error) {
- return openid.Verify(fullURL, discoveryCache, nonceStore)
+ return instance.Verify(fullURL, discoveryCache, nonceStore)
}
// RedirectURL redirects browser
func RedirectURL(id, callbackURL, realm string) (string, error) {
- return openid.RedirectURL(id, callbackURL, realm)
+ return instance.RedirectURL(id, callbackURL, realm)
}
openid.Normalize does not perform HTTP and does not need to change. Once Finding 1 is fixed, this MatchBuiltinExternal instance picks up the new prefix coverage automatically.
Related Vulnerabilities
Other vulnerabilities affecting the same packages