Research/forgejo-openid-discovery-ssrf
0-dayNo CVEHighPublic

Forgejo, unauthenticated OpenID discovery SSRF

Forgejo 16.0.5 OpenID sign-in still discovers through yohcop http.DefaultClient with no hostmatcher. Unauth POST /user/login/openid drives a Yadis GET at loopback. Gitea CVE-2026-58314 was not ported. No CVE yet.

Name
Forgejo, unauthenticated OpenID discovery SSRF
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Forgejo
Affected
Forgejo through v16.0.5 (8d24b7d) with ENABLE_OPENID_SIGNIN. Web installer checkbox defaults on. Docker pre-locked template without [openid] is off unless the operator sets the key.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, forgejo, ssrf, openid, cwe-918, unauthenticated

Webhooks filter. OpenID does not.

I am @abraxas_null. The proof of concept is on GitHub: abraxas/forgejo-openid-discovery-ssrf (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is Forgejo v16.0.5 (8d24b7d52df327118cd776cf461d34e52e0ec1ce). No CVE yet. CWE-918. 8.6 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N) when OpenID sign-in is enabled. Remote SSRF. Unauthenticated.

When [openid] ENABLE_OPENID_SIGNIN is on, unauthenticated POST /user/login/openid hands the attacker URL to yohcop openid-go v1.0.1. That library's package-level helpers use http.DefaultClient. There is no hostmatcher on this path. Webhooks and migrations filter outbound HTTP. OpenID does not.

Why Forgejo

I had just finished Gitea's OpenID leftover class (CVE-2026-58314 / PR 38406). Forgejo closed the association CSRF on the same surface in v16.0.5 and stopped. Same surface, different bug. Worth sitting on.

I only wanted Critical or High. Local is in-scope.

Pinning v16.0.5

Latest GA: v16.0.5, commit 8d24b7d, 2026-09-17 security patches (#14370). Image codeberg.org/forgejo/forgejo:16.0.5. Lab turns FORGEJO__openid__ENABLE_OPENID_SIGNIN=true because a pre-locked Docker template without [openid] is off.

The skip list is half the hunt

v16.0.5 / security-announcements#60 closed OpenID identity association CSRF on GET /user/settings/security. The callback is bound to the logged-in user's session nonce (openIDState). Do not re-file that.

Also closed on this pin:

  • ApplyDiffPatch RCE leftover of PR 13705.
  • CVE-2026-89094 template .git.
  • Git clone/push/migrate http.followRedirects=false.
  • CVE-2026-82556 migrate LookupIP.
  • v16.0.2 CGNAT on the webhook matcher (the 0.0.0.1 hole is a different post).
  • Org-mode ReadFile stub.
  • OAuth redirect_uri skip, client_secret leak, SSO auto-admin. Those did not meet the bar.

OpenID login ATO via auto-link-by-email is not here. Existing user_open_id row signs in as that user under OP control. Register needs ENABLE_OPENID_SIGNUP and refuses a used email. Not High.

CVE-2021-45325 already stopped dumping fetch bodies into the login error. This leftover is the outbound request, not an HTML leak.

Twenty surfaces, one unfiltered Discover

Templates, git-apply, Actions, packages, migrate, hooks, tokens, federation, OpenID, attachments, restricted users, hostmatcher, LFS, keys, restore, OAuth, markup, internal API, secrets, visibility.

Migrations and webhooks import hostmatcher. modules/auth/openid/ does not. That is the leftover.

The package-level client

Go
func RedirectURL(id, callbackURL, realm string) (string, error) {
    return openid.RedirectURL(id, callbackURL, realm)
}

yohcop's package-level helpers use defaultInstance = NewOpenID(http.DefaultClient). Discover does Yadis GET then HTML discovery. http.DefaultClient follows redirects. No IP filter.

SignInOpenIDPost normalizes, checks the URI whitelist (empty = allow all), then openid.RedirectURL. Route group is reqSignOut, so CrossOriginProtection is skipped. The sign-in form has no CSRF field.

The web installer checkbox defaults on: EnableOpenIDSignIn = MustBool(!InstallLock) while unlocked, then written to app.ini. After lock, an omitted key is false. The Docker app.ini template has no [openid] section, so a pre-locked image without the wizard is off unless the operator sets the key. custom/conf/app.example.ini documents ;ENABLE_OPENID_SIGNIN = true. Empty WHITELISTED_URIS allows every URI.

Logged-in POST /user/settings/security/openid is the same RedirectURL sink and does not call allowedOpenIDURI. Authenticated SSRF even if a login whitelist exists.

Gitea 1.27.0 (PR 38406 / CVE-2026-58314) put a hostmatcher-filtered http.Client into a dedicated openid-go instance. That patch is not here.

Wrong turns, in the order I made them

Re-filing the association CSRF. The nonce is in tree. Cross-user plant needs the victim's session cookie plus that nonce.

Treating a Docker pre-locked image as the default. The wizard writes the checkbox on. The omitted key after lock is false. Lab sets the env.

Expecting the old OpenID error-string leak. CVE-2021-45325 closed the HTML dump. The request still happens.

Completing an OpenID login. The leftover is Discover, not a session. The lab stops at the Yadis GET.

A reverse shell. Theatre. The witness is Accept: application/xrds+xml on loopback.

What an attacker can do

No account. POST an OpenID URL. The Forgejo host fetches it.

Privilege Method / sink Recon / integrity / availability Lab-proven or inferred
Drive a Yadis GET at loopback Unauth POST /user/login/openid with openid=http://127.0.0.1:18080/ Recon (outbound HTTP from the Forgejo host) Lab-proven. Oracle saw GET / with Accept: application/xrds+xml from 127.0.0.1
Probe RFC1918, CGNAT, link-local, IMDS Same http.DefaultClient Discover, no IP filter Recon Inferred from the client. Lab used loopback only
Repeat from a signed-in add-OpenID POST /user/settings/security/openid also calls RedirectURL and skips allowedOpenIDURI Recon Inferred from the handler. Lab used the unauth login POST
Read response bodies in the login error After CVE-2021-45325, errors no longer dump the fetch Recon Lab-proven closed. This leftover is the request, not an HTML leak

Closed on this pin: OpenID association CSRF (openIDState session nonce), webhook/migrate hostmatcher (still used there), git followRedirects=false, migrate LookupIP CVE-2026-82556. The lab does not complete an OpenID login and does not return a shell.

The lab

Source of truth is lab/. Image codeberg.org/forgejo/forgejo:16.0.5 with OpenID sign-in on. Host bind 127.0.0.1:19330. Oracle shares the Forgejo netns and binds 127.0.0.1:18080. Unauth POST openid=http://127.0.0.1:18080/. Oracle records the Yadis GET.

Plain text
git clone https://github.com/abraxas/forgejo-openid-discovery-ssrf
cd forgejo-openid-discovery-ssrf
python3 forgejo-openid-discovery-ssrf-Abraxas-Labs.py

Last lab run, trimmed:

Plain text
IOC post-openid status=200
IOC xrds-hits count=1
SUCCESS forgejo-openid-discovery-ssrf how=live post=/user/login/openid openid=http://127.0.0.1:18080/ path=/ accept=application/xrds+xml peer=127.0.0.1 post-status=200 FORGEJO-OPENID-DISCOVERY-WITNESS

I am not printing an OpenID URL you can paste at someone else's login form. The missing hostmatcher is the useful part.

The fix

Same as Gitea PR 38406: a hostmatcher-filtered http.Client into a dedicated openid-go instance. Keep WHITELISTED_URIS as a second gate. Block loopback, RFC1918, CGNAT, link-local, and metadata.

Same tag leftovers: keys IDOR, webhook 0.0.0.1 SSRF, restore-repo file open.

References

Forgejo, unauthenticated OpenID discovery SSRF · Abraxas Labs