CVE-2026-20904

Gitea OpenID Visibility Toggle IDOR: The "Trust Me, Bro" Update Query

Alon Barad
Alon Barad
Software Engineer

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.

  1. Reconnaissance: The attacker toggles their own OpenID visibility and captures the request using a proxy like Burp Suite or Caido.
  2. Analysis: The request will likely look like POST /user/settings/security/openid/toggle with a body or query parameter id=105.
  3. Weaponization: The attacker sends the request to the Intruder (or writes a simple Python script) to iterate the id parameter from 1 to N.

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:

  1. Upgrade: Pull the latest docker image or binary for Gitea 1.25.4.
  2. Audit: If you suspect foul play, check your database logs (if enabled with high verbosity) for UPDATE user_open_id queries where the id sequence looks linear and rapid, originating from a single IP or session.
  3. Code Review: Grep your own codebases for UPDATE ... WHERE id = ?. If you find one, ask yourself: "Who owns this ID?"

Fix Analysis (1)

Technical Appendix

CVSS Score
6.5/ 10
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L
EPSS Probability
0.02%
Top 97% most exploited

Affected Systems

Gitea < 1.25.4

Affected Versions Detail

Product
Affected Versions
Fixed Version
Gitea
Gitea
<= 1.25.31.25.4
AttributeDetail
CWE IDCWE-639
Attack VectorNetwork
CVSS v3.16.5
ImpactIntegrity Loss
Privileges RequiredLow (Authenticated)
Exploit StatusPoC Available
CWE-639
Insecure Direct Object Reference (IDOR)

Authorization Bypass Through User-Controlled Key

Vulnerability Timeline

Patch Merged (PR #36346)
2026-01-13
CVE-2026-20904 Published
2026-01-22
Gitea 1.25.4 Released
2026-01-23

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.