Gitea OpenID Visibility Toggle IDOR: The "Trust Me, Bro" Update Query
Jan 24, 2026·6 min read·5 visits
Executive Summary (TL;DR)
Gitea developers forgot the golden rule of access control: verify ownership. By sending a request to the OpenID visibility toggle endpoint and iterating through IDs, an attacker could hide or show OpenID connections for every user on the instance. The fix involved adding a simple `AND uid = ?` clause to the SQL query.
A classic Insecure Direct Object Reference (IDOR) vulnerability in Gitea versions prior to 1.25.4 allowed authenticated users to toggle the visibility of OpenID credentials belonging to any other user. The flaw stemmed from a database update query that checked the record ID but failed to verify the record owner.
The Hook: Who Turned Out the Lights?
In the world of web application security, there are sophisticated memory corruption bugs, complex race conditions, and then there are the "oops" moments. CVE-2026-20904 falls squarely into the latter category. It is a story about Gitea, a delightful self-hosted Git service, and how it trusted user input a little too much.
OpenID Connect is a fantastic feature. It lets users log in with their Google, GitHub, or generic OIDC providers. Gitea allows users to manage these linked accounts, including a privacy setting: a simple toggle to "Show" or "Hide" the OpenID URI on their public profile. It seems harmless enough. A boolean flag. On or off.
But here is the kicker: the mechanism controlling that switch didn't care whose hand was on it. It was like a light switch in a hotel hallway that, instead of controlling the light above it, accepted a room number as input and toggled the lights in that room. If you knew the room numbers (or just guessed them), you could throw a disco party in someone else's suite without ever stepping inside.
The Flaw: A Case of Missing Identity
The vulnerability is a textbook Insecure Direct Object Reference (IDOR), or as I like to call it, "Database Roulette." The issue lived in models/user/openid.go within the ToggleUserOpenIDVisibility function.
When a user clicked that toggle button, the frontend sent a request containing the ID of the OpenID record. The backend received this ID and immediately constructed a SQL query to update the database. The logic was dangerously simple: "Find the row with this ID, and flip the show bit."
Here is where the logic fell apart. The database schema relies on an auto-incrementing integer for the primary key (id). However, the table also contains a uid column, which links the record to a specific user. The vulnerable function completely ignored the uid. It assumed that if you were asking to toggle record #1337, you must own record #1337.
> [!NOTE] > IDORs are particularly embarrassing because they aren't failures of technology; they are failures of logic. The code did exactly what it was told to do. It just wasn't told to check user permissions.
The Code: The Smoking Gun
Let's look at the code. This is the difference between "working software" and "secure software."
The Vulnerable Code (Pre-1.25.4):
// ToggleUserOpenIDVisibility toggles visibility of an openid address of given user.
func ToggleUserOpenIDVisibility(ctx context.Context, id int64) (err error) {
// DANGER: Updates based solely on the Primary Key (id)
_, err = db.GetEngine(ctx).Exec("update `user_open_id` set `show` = not `show` where `id` = ?", id)
return err
}Notice the function signature. It takes ctx and id. It doesn't even ask who is making the request. The SQL query is blind to ownership.
The Patched Code (1.25.4):
// ToggleUserOpenIDVisibility toggles visibility of an openid address of given user.
func ToggleUserOpenIDVisibility(ctx context.Context, id int64, user *User) error {
// FIXED: Now checks both Primary Key (id) AND Foreign Key (uid)
affected, err := db.GetEngine(ctx).Exec(
"update `user_open_id` set `show` = not `show` where `id` = ? AND uid = ?",
id,
user.ID
)
if err != nil {
return err
}
// If no rows were affected, it means the ID exists but belongs to someone else
// (or doesn't exist at all).
if n, _ := affected.RowsAffected(); n != 1 {
return util.NewNotExistErrorf("OpenID is unknown")
}
return nil
}The fix is elegant in its simplicity. They updated the signature to require the user object (the authenticated actor) and appended AND uid = ? to the SQL query. Now, if I try to toggle your record, the database says, "I found the ID, but the UID doesn't match," resulting in zero rows affected.
The Exploit: Flipping Switches
Exploiting this requires valid authentication on the Gitea instance, but any low-level user will do. Once logged in, the attack vector is trivial.
- Reconnaissance: The attacker toggles their own OpenID visibility and captures the request using a proxy like Burp Suite or Caido.
- Analysis: The request will likely look like
POST /user/settings/security/openid/togglewith a body or query parameterid=105. - Weaponization: The attacker sends the request to the Intruder (or writes a simple Python script) to iterate the
idparameter from 1 toN.
Because OpenID record IDs are likely sequential integers (1, 2, 3...), the attacker doesn't even need to guess. They can simply brute-force the entire integer space.
The result? Chaos. Users who intended to keep their OpenID providers private suddenly have them exposed. Users who relied on them being public suddenly find them hidden. It's a low-tech Denial of Service on the configuration integrity of the platform.
The Impact: Privacy Roulette
While this isn't a Remote Code Execution (RCE) that burns the server to the ground, we shouldn't dismiss the impact.
1. Privacy Leakage: OpenID URIs can sometimes leak personal information. If a user configured a custom OpenID provider that includes their real name or personal domain in the URL, forced visibility exposes this to the public.
2. Data Integrity Loss: Security isn't just about confidentiality; it's about integrity. If an attacker can modify your settings without your consent, the system's integrity is compromised.
3. Social Engineering Prep: By toggling settings and observing the results, an attacker might be able to map out which users are active and which OpenID providers are most common on the target infrastructure, aiding in targeted phishing campaigns.
CVSS Score Analysis (6.5): The score reflects the fact that integrity (I:L) is violated. The attack is network-based (AV:N), requires low privileges (PR:L), and is easy to execute (AC:L).
The Fix: Trust No One
The mitigation here is straightforward: upgrade. Gitea version 1.25.4 patches this vulnerability effectively.
If you are a developer looking at this, let it be a lesson: Never rely on an object ID alone for database operations in a multi-user environment. Always scope your queries to the authenticated user.
Remediation Steps:
- Upgrade: Pull the latest docker image or binary for Gitea
1.25.4. - Audit: If you suspect foul play, check your database logs (if enabled with high verbosity) for
UPDATE user_open_idqueries where theidsequence looks linear and rapid, originating from a single IP or session. - Code Review: Grep your own codebases for
UPDATE ... WHERE id = ?. If you find one, ask yourself: "Who owns this ID?"
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:LAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
Gitea Gitea | <= 1.25.3 | 1.25.4 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-639 |
| Attack Vector | Network |
| CVSS v3.1 | 6.5 |
| Impact | Integrity Loss |
| Privileges Required | Low (Authenticated) |
| Exploit Status | PoC Available |
MITRE ATT&CK Mapping
Authorization Bypass Through User-Controlled Key
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.