Fixing a Sneaky Security Bug in Gitea: Users Could No Longer Change Someone Else’s Primary Email
- Part 1: How fzf Just Became Way More Memory-Efficient: The Bitmap Cache Upgrade
- Part 2: Unlocking Faster Fuzzy Finding: How a Smart Work Queue Made fzf Even Quicker
- Part 3: Speeding Up fzf: How a Tiny Change Made the World's Fastest Fuzzy Finder Even Faster
- Part 4: Fixing a Sneaky XSS Vulnerability in Hugo: Inside the Commit That Makes Markdown Rendering Safer
- Part 5: Fixing Syncthing’s REST API: Why “application/json; charset=utf-8” Was Wrong and How a Simple Commit Made It Right
- Part 6: Fixing UI Lag | Lazygit just got a major speed boost for one of its most frustrating pain points
- Part 7: Traefik PR #12880: A Tiny Fix That Squashes Unnecessary Allocations and Kills Routing Lag
- Part 8: Fixing Header Sanitization in Traefik’s PassTLSClientCert Middleware: A Deep Dive into PR #12875
- Part 9: Fixing URL Prefix Stripping in Traefik: Inside Pull Request #12863
- Part 10: Gitea Just Got More Secure: Fixing OAuth2 Authorization Code Expiry and Reuse
- Part 11: Fixing a Sneaky Security Bug in Gitea: Users Could No Longer Change Someone Else’s Primary Email
How two quick pull requests ( #36586 + #36607 ) closed an authorization hole in the account settings 🔗
TL;DR
A logged-in user could tamper with a form and change another user’s primary email address.
Fixed in 24 hours by adding a simple ownership check + better error message + tests.
Backported to Gitea 1.25 so everyone is protected.
Goal achieved ✅ No more ID tampering, clear error for users, and the code is now secure by design.
1. The Problem (Why This Bug Mattered) 🔗
Gitea’s User Settings → Account → Email addresses page lets you mark one of your emails as “Primary”.
Behind the scenes, the page sends a tiny hidden field containing the email record ID.
The server was supposed to say:
“Only the owner of this email can make it primary.”
But it didn’t check.
Result: Any logged-in user could change the primary email flag on any email record in the entire database — even emails belonging to other users.
This is a classic IDOR (Insecure Direct Object Reference) vulnerability. It didn’t let you steal accounts, but it could:
- Confuse users (“Why did my primary email suddenly change?”)
- Break notification flows or 2FA recovery
- Open the door for more serious issues if combined with other bugs
2. How the Bug Actually Worked (Technical Breakdown) 🔗
When you clicked “Make Primary”, the browser sent something like:
POST /user/settings/account
_method=PRIMARY&id=42 ←←← anyone could put any number here
Old code in routers/web/user/setting/account.go (simplified):
if ctx.FormString("_method") == "PRIMARY" {
err := user_model.MakeActiveEmailPrimary(ctx, ctx.FormInt64("id")) // ← NO owner check!
...
}
The model function never verified that the email belonged to ctx.Doer (the logged-in user).
So Gitea happily updated the is_primary column on whoever’s email record had ID 42.
3. The Fix – What Changed (and Why It’s Elegant) 🔗
PR #36586 (main branch, merged Feb 12, 2026) by lunny did three smart things:
Added an
ownerIDparameter to the core functions
New helpers inmodels/user/email_address.go:func MakeActiveEmailPrimary(ctx context.Context, ownerID, emailID int64) error func MakeInactiveEmailPrimary(...) // same patternInside
makeEmailPrimaryInternal, Gitea now does:email, err := GetEmailAddressByID(ctx, emailID) if err != nil || email.UID != ownerID { return user_model.ErrEmailAddressNotExist{} // reuse existing error }Updated the web handler to always pass the current user’s ID:
if ctx.FormString("_method") == "PRIMARY" { if err := user_model.MakeActiveEmailPrimary(ctx, ctx.Doer.ID, ctx.FormInt64("id")); err != nil { if user_model.IsErrEmailAddressNotExist(err) { ctx.Flash.Error(ctx.Tr("settings.email_primary_not_found")) ... } } }Improved the user experience
New translation string (English):“The selected email address could not be found.”
Users now get a friendly flash message instead of a cryptic server error.
Bonus: Two new integration tests in tests/integration/user_settings_test.go that deliberately try invalid and foreign email IDs — ensuring the bug can never sneak back in.
Before vs After (visualized):
IDOR vulnerability in Gitea
Fixing IDOR vuln. in Gitea
4. The Backport – Everyone Gets the Fix 🔗
PR #36607
(released Feb 14, 2026) by the same author is a clean backport to the release/v1.25 branch.
It includes the exact same security fix plus tiny test + translation tweaks needed for the older release.
Now Gitea 1.25.5 users are protected too.
5. Goals of the Pull Requests – Fully Achieved 🔗
- ✅ Security: No user can touch another user’s email records anymore.
- ✅ Correctness: Ownership check is now explicit and centralized in the model layer.
- ✅ Usability: Clear error message instead of silent failure or ugly stack trace.
- ✅ Test coverage: New tests prevent regression.
- ✅ Maintenance: Backported so the whole community benefits quickly.
- ✅ Minimal change: The fix is small, readable, and follows Gitea’s existing patterns.
If you run a Gitea instance, update to the latest 1.26 or 1.25.5 patch.
The bug is gone, the code is cleaner, and your users’ email settings are finally safe from accidental (or intentional) tampering.
I hope you enjoyed reading this post as much as I enjoyed writing it. If you know a person who can benefit from this information, send them a link of this post. If you want to get notified about new posts, follow me on YouTube , Twitter (x) , LinkedIn , and GitHub .
- Part 1: How fzf Just Became Way More Memory-Efficient: The Bitmap Cache Upgrade
- Part 2: Unlocking Faster Fuzzy Finding: How a Smart Work Queue Made fzf Even Quicker
- Part 3: Speeding Up fzf: How a Tiny Change Made the World's Fastest Fuzzy Finder Even Faster
- Part 4: Fixing a Sneaky XSS Vulnerability in Hugo: Inside the Commit That Makes Markdown Rendering Safer
- Part 5: Fixing Syncthing’s REST API: Why “application/json; charset=utf-8” Was Wrong and How a Simple Commit Made It Right
- Part 6: Fixing UI Lag | Lazygit just got a major speed boost for one of its most frustrating pain points
- Part 7: Traefik PR #12880: A Tiny Fix That Squashes Unnecessary Allocations and Kills Routing Lag
- Part 8: Fixing Header Sanitization in Traefik’s PassTLSClientCert Middleware: A Deep Dive into PR #12875
- Part 9: Fixing URL Prefix Stripping in Traefik: Inside Pull Request #12863
- Part 10: Gitea Just Got More Secure: Fixing OAuth2 Authorization Code Expiry and Reuse
- Part 11: Fixing a Sneaky Security Bug in Gitea: Users Could No Longer Change Someone Else’s Primary Email