“Add Missing Checks to Several Handlers and Models”: Gitea Ships Twelve CVEs in One Release

Gitea 28.2.0 was published at 18:25:03Z on 18 October 2026. Twelve GitHub security advisories went live between 18:44:34Z and 18:44:45Z — eleven seconds for the lot — and the matching twelve CVE records reached NVD at 19:16:32Z to 19:16:33Z, all still vulnStatus: Received, none carrying a CVSS vector at NVD. The severity numbers exist only in the advisories. This is a maintenance release of the second-most-deployed self-hosted Git forge in the world: 58,552 stars, 7,286 forks, Go, MIT, created November 2016, and the repository was pushed to twenty-one minutes before the release went out.

Twelve CVEs in one release invites the reflex reading — a bad month for Gitea — and that reading is wrong in an interesting way. These are not twelve unrelated defects. Five of them share one pull request, and the maintainers titled that pull request with the diagnosis already written: “fix: add missing checks to several handlers and models.” It is rare for a project to name its own defect class in a commit subject. Having done so, it is worth taking them at their word and asking what a missing check actually looked like here, because across ten of the twelve records the answer is the same shape: a predicate, a middleware, or a type filter that was present on one code path and absent on its sibling.

What shipped, and what it is worth

The full set, with the severities as the advisories record them (NVD has none):

  • CVE-2026-104627 — 8.1 high. OpenID 2.0 association callback accepts an unsolicited assertion; attacker identity is linked to the signed-in victim, then used to log in as them. Bypasses local TOTP and WebAuthn.
  • CVE-2026-109553 — 7.5 high. SSH certificate principals resolved against all stored public keys instead of only principal keys; a certificate listing a victim’s public-key blob authenticates as the victim.
  • CVE-2026-110324 — 6.5 medium. Private-profile visibility enforced on GET but not on POST /{username}. Fixed in 28.1.0, disclosed now.
  • CVE-2026-105964 — 6.5 medium. Actions artifact v4 Twirp handlers read the whole request body with no size limit; a runner-token holder OOM-kills the process.
  • CVE-2026-110494 — 6.3 medium. Release attachments checked repo and release but not whether the attachment was already an issue attachment — exposing, renaming and deleting it.
  • CVE-2026-105661 — 5.3 medium. Unauthenticated nil-pointer panic on /{username}/-/repositories for individual users.
  • CVE-2026-107686 — 4.3 medium. Web UI accepted reactions on locked issues from users without write access; the API refused them.
  • CVE-2026-109844 — 4.3 medium. GET /api/v1/user/subscriptions returned watched-private-repo metadata after the watcher lost access. Fixed in 1.27.0 and 28.0.0, disclosed now.
  • CVE-2026-110311 — 4.3 medium. Draft release creation returned an assets list matching every attachment not linked to a release — across repositories the caller cannot read.
  • CVE-2026-97204 — 3.5 low. Label deletion compared a repository ID against an organization ID; the two sequences overlap.
  • CVE-2026-107618 — low, no CVSS. POST /{owner}/{repo}/topics registered without the sign-in middleware that carries cross-origin protection. CSRF.
  • CVE-2026-97859 — low, no CVSS. Seven API routes under /api/v1/users/{username} registered without individualPermsChecker. Fixed in 1.27.3 and 28.0.0, disclosed now.

None of the twelve appear in CISA’s Known Exploited Vulnerabilities catalogue, checked directly against catalogue version 2026.10.16 and its 1,748 entries. The catalogue does carry one Gitea record — CVE-2026-60004, the diffpatch git-hook RCE — which is a useful reminder of what the exploited end of this product’s range looks like, and that today’s batch is not that.

The one-token fixes

Pull request #39839 (merged to main at 13:29:50Z) and its backport #39847 (merged to release/v28 at 17:50:36Z, thirty-five minutes before the release) together carry five CVEs across 28 files and +280/−211 lines. The security-relevant portions are startlingly small, and two of them are worth quoting because the size is the argument.

CVE-2026-109553, the 7.5, is a single line in models/asymkey/ssh_key.go:

-		Where("content = ?", principalKey).
+		Where("content = ? AND type = ?", principalKey, KeyTypePrincipal).

The public_key table stores user keys, deploy keys and principal keys in the same rows, distinguished by type. The principal lookup matched on content alone, so a certificate whose ValidPrincipals entry happened to equal the text of someone’s SSH public key resolved to that person’s row — and the HTTP-signature auth path then calls GetUserByID(publicKey.OwnerID). The advisory is blunt about what that means operationally: “Audit logs attribute the actions to the victim.” The preconditions are real but narrow — the instance must trust an SSH user CA via SSH_TRUSTED_USER_CA_KEYS, and either accept arbitrary principals (SSH_AUTHORIZED_PRINCIPALS_ALLOW=anything, which the project documents as performing no content check) or the attacker must be able to get that CA to sign. The default with a CA present is username,email, which blocks registering a foreign key blob as a principal but does nothing about the CA-private-key path and adds no type filter either. Instances that trust no user CA are unaffected.

What makes this one more than a typo is the comment sitting directly above the fixed line, which predates the fix: “In the future, the existing principal keys should be migrated to use the fingerprint (‘principal:{name}’) instead of the content.” Someone had already noticed that keying principals by raw content was the wrong design and written it down as future work. The authentication consequence of the interim design was not the part that got noticed.

CVE-2026-107618, the CSRF, is one middleware in routers/web/web.go:

-	}, context.RepoAssignment, reqRepoAdmin, context.RepoMustNotBeArchived())
+	}, optSignIn, context.RepoAssignment, reqRepoAdmin, context.RepoMustNotBeArchived())

Note what was already there. reqRepoAdmin — the authorization check — was present and working. The route was not unprotected in the permissions sense; a non-admin could not change the topics. What was missing was the sign-in middleware, which is where Gitea’s cross-origin protection lives. So a request forged by a third-party site arrived carrying the victim’s session cookie, satisfied reqRepoAdmin honestly, and changed the topics. The authorization check passing is not evidence that the request was wanted. This is the same asymmetry as the rest of the batch, pushed one layer down: the gate that answers may this user do this ran, and the gate that answers did this user ask for this did not.

CVE-2026-110311 is three lines in models/repo/attachment.go, and the added comment is the whole bug report:

+	if issueID == 0 { // unsaved issue, would match every issue-less attachment
+		return []*Attachment{}, nil
+	}

A draft release has no ID yet. WHERE issue_id = 0 AND comment_id = 0 is a perfectly valid query that matches every attachment in the instance not yet linked to an issue or a release. The sentinel value for “not saved” and the sentinel value for “not an issue attachment” are the same zero, and the query could not tell them apart. CVE-2026-97204 is the identical move in a different table: a label-deletion handler compared one untyped ID against whichever namespace the label used, and repository IDs and organization IDs come from overlapping sequences. Both are the database-layer form of a missing predicate, and both are the kind of defect that only shows up when someone asks what a query matches rather than what it was written to match.

Where the check went instead

The fix for CVE-2026-107686 — reactions accepted on locked issues through the web UI, refused through the API — is the most instructive part of #39839, because the maintainers did not add a check to the web handler. They deleted code. routers/web/repo/issue_comment.go goes +37/−102; routers/web/repo/issue.go goes +16/−35; routers/api/v1/repo/issue_reaction.go goes +4/−19. In their place, one new function in services/issue/issue.go, with its contract written as a doc comment:

// CanSelfChangeOrAccessContent checks whether the doer can access the content:
// * If the doer is the content poster, then the doer can access the content if they have read access to issues.
// * If the doer is not the content poster, then the doer can access the content if they have the required access mode to the unit type (issues or pull requests).
// * If the issue is locked, then the doer can only access the content if they have write access.

That is the right shape, and it is the same shape we saw in VulnCheck’s 122-CVE batch: when the same decision is re-implemented at every call site, the defect is not that one implementation is wrong, it is that there are several. Gitea’s locked-issue rule existed in the API and not in the web UI because it was written twice. One function, called from both, cannot diverge. The net line count going down while a CVE is fixed is the clearest signal available that a project addressed a class rather than an instance.

CVE-2026-97859 got the same treatment at the routing layer. The individualPermsChecker middleware had its inlined visibility switch replaced by a call to the shared user_model.IsUserVisibleToViewer, and the middleware was then appended to the route groups that had been registered without it — /api/v1/user/subscriptions, /starred, /users/{username}/orgs and the rest. The check was never absent from the codebase. It was absent from a list.

Three of these were fixed before you heard about them

Three records in today’s batch describe defects that were patched in earlier releases and disclosed only now, which the release blog states plainly for each:

  • CVE-2026-109844 — fixed in 1.27.0 and 28.0.0. Version 1.27.0 shipped 13 July 2026. Ninety-seven days to disclosure.
  • CVE-2026-97859 — fixed in 1.27.3 and 28.0.0. Version 1.27.3 shipped 29 August 2026. Fifty days.
  • CVE-2026-110324 — fixed in 28.1.0, which shipped 6 October 2026. Twelve days.

This is not a criticism of the embargo — coordinated disclosure routinely lags the patch, and these are low-to-medium information leaks. It is an observation about who the gap falls on. An operator who upgraded promptly was protected throughout and never knew they had been exposed. An operator who pinned, as operators of self-hosted forges frequently do, spent between twelve and ninety-seven days running a known-defective build with no advisory, no CVE, and a changelog line that read like an ordinary bug fix. The information asymmetry during an embargo is intended to disadvantage attackers; its cost is paid entirely by the conservative part of the install base.

The 28.2.0 changelog sharpens this. It has no security section. Every one of these fixes appears under Bug Fixes, formatted identically to “Display MathML has no space below it” and “Keep line breaks in Telegram rich messages.” The line carrying five CVEs including a 7.5 authentication bypass is the last entry in the list and reads, in full: “Add missing checks to several handlers and models (#39839) (#39847).” The project does publish a proper security section with per-CVE descriptions and full credits — on the release blog, a different document on a different domain. An operator who reads git log, the GitHub release body, or any tool that consumes it sees none of it.

What a scanner sees: the module path moved

Gitea did two unusual things to its own identity this year, and the combination produces a measurable gap. First, the Go module path changed. We read go.mod at each tag directly:

v1.26.4    module code.gitea.io/gitea
v1.27.0    module gitea.dev
v1.27.3    module gitea.dev
v28.0.0    module gitea.dev
v28.2.0    module gitea.dev

Second, the version series reset: 1.27.3 on 29 August, then 28.0.0 on 29 September, 28.1.0 on 6 October, 28.2.0 today. Now query OSV, which is what a dependency scanner does:

  • code.gitea.io/gitea @ 1.26.4 — 40 vulnerabilities
  • code.gitea.io/gitea @ 1.27.3 — 4
  • gitea.dev @ 1.27.0 — 2
  • gitea.dev @ 1.27.3 — 0
  • gitea.dev @ 28.0.0 — 0
  • gitea.dev @ 28.1.0 — 0

The two hits at gitea.dev @ 1.27.0 are both the same finding — GHSA-rcr6-4jqh-j84m and its Go-advisory twin GO-2026-6433, the diffpatch RCE. Every other version of the current module path returns nothing. The forty records that accumulated against the old path did not follow the rename.

All twelve of today’s CVE identifiers and all twelve GHSA identifiers return 404 from OSV at the time of writing, which is unremarkable hours after publication and will resolve. The structural problem will not. Eleven of the twelve advisories declare their affected package as gitea.dev — including CVE-2026-109844, whose vulnerable range is <= 1.26.4, a range that lies entirely before the module path gitea.dev existed. A version constraint and a package identifier that disagree about which era of the project they describe will not match anything correctly on either side of the rename. CVE-2026-105964’s advisory is the one that gets this right, naming code.gitea.io/gitea explicitly and recording that the module path changed at v1.27.0 — because the reporter wrote that section by hand.

This is a different failure from the ones we documented at Coder and runwasi, where the product had no usable package coordinate at all. Gitea has an excellent coordinate. It has two of them, consecutively, and the advisory database is carrying ranges from one era under the label of the other. If you scan a Gitea deployment by module path, confirm which path your SBOM records and check both.

The one that already names its own staleness

CVE-2026-105964 deserves a note on how to read these advisories. Gitea publishes reporter-submitted analysis close to verbatim, which is why the GHSA bodies here are unusually detailed — file-and-line references, call chains, quoted source. The trade is that sections reflect the state of the world when the report was written. The published advisory for the artifact-v4 memory exhaustion contains a “Fix status” line reading “not fixed through v1.27.3; no commit has ever added a body size limit to parseProtobufBody.” That was true when submitted. PR #39818 merged on 17 October at 10:40:05Z and its backport #39822 on 18 October at 06:17:46Z, adding the bound; the release notes record the limit as 1 MiB. The advisory shipped the next day still carrying the sentence. Read the affected-and-patched metadata, not the prose, when they disagree — and read the prose for everything the metadata cannot express, which here is most of the value.

The same advisory is worth reading for a second reason: it is the only one in the batch whose attack prerequisite is a CI runner token. Any user who can push a workflow to a repository with Actions enabled — the default — obtains one, and one oversized POST to any of five Twirp endpoints kills the process for every user of the instance, including git operations and the package registry. It is re-exploitable immediately on restart. That is a forge-wide outage obtainable by anyone with push access to any repository, which is a different population from “administrators.” We have spent a lot of 2026 watching what attackers do with Actions credentials; this is a reminder that the runner token is also an authenticated channel into the server itself, not only a key to secrets.

The highest-severity bug is the one about asking

CVE-2026-104627, at 8.1 the top of the batch, is the cleanest statement of the whole theme. The OpenID association endpoint sits behind reqSignIn, so Gitea knows a user is authenticated. The external provider signs an assertion, so Gitea knows an OpenID identity is genuine. Both facts are true, both are verified, and neither of them is the question. The question is whether this session started this linking, and nothing in the flow recorded that. The return URL carried no unguessable transaction reference and no server-side pending-link record existed. OpenID 2.0 permits unsolicited positive assertions by design, so an attacker-controlled provider can manufacture the callback without any linking flow having begun, and the credential insertion happens on a GET, with no password or MFA re-confirmation gating the addition of a new login method.

The escalation is what makes it an 8.1 rather than an annoyance: once the attacker identity is attached, signing in through the OpenID path reaches handleSignInFull directly and never enters the branch where the password flow evaluates TOTP and WebAuthn. The victim’s second factor is not defeated; it is simply on a road the attacker does not drive down. We made the same observation about the MCP Python SDK account takeover and about Better Auth’s four-CVE batch: an MFA requirement is a property of a code path, not of an account, and every alternate authentication route is a place where that property has to be re-established by hand. The fix — a short-lived state value bound to the user, from PR #39642 — is the standard remedy and arrived in the same pull request that validated scope for OAuth2, which tells you the maintainers recognised it as a class too.

Why a Git forge belongs in this feed

Gitea is infrastructure that holds source code, runs CI, stores package registries and issues SSH credentials, and the industry has spent this year wiring coding agents into exactly that surface. An agent with a Gitea token inherits whatever the token can reach; an agent that pushes a workflow gets a runner token; a forge account takeover is a code-supply-chain compromise with a commit signature attached. The two high-severity records here both end in the server believes you are someone else, and says so in the audit log, which is precisely the failure mode that makes post-incident reconstruction hardest.

And the defect class generalises past Gitea without much effort. Every system in this feed that has shipped a “missing check” CVE this quarter had the check somewhere: in the API but not the UI, on GET but not POST, in the route group above but not the one below, on the filename parameter but not the ID parameter four lines up. The vulnerability is not the absence of security thinking. It is the assumption that a decision made once applies everywhere the code can reach the same object.

What to do

  • Upgrade to 28.2.0, and do not stop at the version an advisory names. Three of the twelve list a fixed version in the 1.27.x or 28.0.0/28.1.0 range because that is when the patch landed — those ranges are up to four releases behind the one that fixes the other nine. 28.2.0 is the only build that addresses the whole batch.
  • If you trust an SSH user CA, treat CVE-2026-109553 as the urgent one regardless of its 7.5. Check SSH_TRUSTED_USER_CA_KEYS and SSH_AUTHORIZED_PRINCIPALS_ALLOW first. If principals are set to anything, assume registered principal rows could collide with user keys and audit the public_key table for duplicate content across differing type values before and after upgrading.
  • Audit OpenID and OAuth login paths for MFA parity, not just MFA presence. The question is not whether TOTP or WebAuthn is enforced; it is whether every route that can create a session evaluates them. Then ask the second question CVE-2026-104627 raises: can any flow attach a new credential to an account without re-confirming the existing one?
  • Check which module path your scanner is querying. gitea.dev returns almost nothing from OSV across the current release series while code.gitea.io/gitea carries forty records at 1.26.4. A clean scan result against the new path is not evidence of a clean deployment.
  • Read the release blog, not the changelog. Gitea’s GitHub release body has no security section; the per-CVE detail and the researcher credits live only on blog.gitea.com. Any monitoring that consumes release notes or git log will see five CVEs rendered as one routine bug-fix line.
  • Bound your own request bodies at the proxy. The artifact-v4 fix caps at 1 MiB, but CVE-2026-105964 is a reminder that any handler reading an unbounded body is a process-kill primitive for anyone with a runner token. A client_max_body_size in front of the forge is a deployment measure, not a fix, and it is still worth having.
  • Ask the #39839 question of your own codebase. Where does a permission decision get made more than once for the same object? Grep for a check that exists in your API handlers and look for the UI handler that does the same job. The pull request title is a reusable audit prompt.

Our verification was read-only and external. All twelve CVE identifiers, publication timestamps, vulnStatus values, source identifier, descriptions, CWEs and references were read first-hand from the NVD 2.0 API, as was the absence of CVSS data on every record. All twelve GHSA identifiers, severities, CVSS v3.1 vectors and scores, affected package coordinates, version ranges, patched versions and publication timestamps were read first-hand from the GitHub repository security-advisory API, and the quoted advisory prose for CVE-2026-104627, CVE-2026-109553 and CVE-2026-105964 is verbatim from those advisories. Release tags, publication timestamps, the full 28.2.0 release body, repository metadata (58,552 stars, 7,286 forks, Go, MIT, created 1 November 2016, not archived) and the pull-request metadata, file lists, line counts and diffs for #39839, #39847, #39743, #39752, #39642, #39744, #39818, #39822 and #39004 were read first-hand from the GitHub API; every line of Go and every code comment above is quoted verbatim from those diffs. The go.mod module lines were read at each named tag. The OSV counts, the module-path queries and the 404s on all twenty-four new identifiers were measured directly against api.osv.dev. The security section, per-CVE descriptions and researcher credits were read first-hand from blog.gitea.com. The KEV check was run directly against catalogue 2026.10.16. Brave Search was used for discovery only and returned CVE mirrors and automated scoring pages with no independent technical analysis of any of the twelve records. We installed nothing, stood up no Gitea instance, created no account or certificate, sent no request to any third-party instance, and executed no proof of concept; preconditions and impacts are described only to the extent already published in the project’s own advisories.

Sources: