CVE-2026-123012 is a read-to-write privilege escalation in Gogs 0.14.3 caused by a case-sensitivity desync between the authorization classifier and the request dispatcher in internal/route/repo/http.go, where the classifier compares the Git action verb case-sensitively and defaults to pull while the dispatcher lowercases the same verb before routing, so capitalising a single letter in the URL, info/Refs instead of info/refs, lets an authenticated read-only collaborator push to a private repository. I found and reported this vulnerability, it was assigned CVE-2026-123012 (NVD, MITRE) at CVSS 4.0 base 7.1 HIGH, and I confirmed it end-to-end with a differential test against a patched build. The vendor introduced the bug in 0.14.3 while refactoring the same handler to fix a sibling issue, flipping the classifier’s default from fail-closed to fail-open in the process.

This post walks through the patch-diff that surfaced it, the root cause across both functions, and the differential confirmation where a read-only collaborator writes a branch on the stock build and gets denied on the patched one.

Scope

Gogs is a lightweight, self-hosted Git forge written in Go that exposes the Git Smart HTTP protocol on the same port as its web UI, which means the authorization code path is directly reachable from the network and in practice from the public internet. All testing was performed locally against two operator-compiled builds (stock 0.14.3 and the same tree with a one-line fix) and no third-party instance was contacted.


Advisory summary

FieldValue
CVECVE-2026-123012
CVSS v4.07.1 HIGH AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:L/SC:N/SI:L/SA:N
ProductGogs (self-hosted Git service)
Affected0.14.3 and main at time of writing
Introduced0.14.3, June batch refactor of the Git HTTP handler; 0.14.2 failed closed on this path
ClassCWE-178 (improper handling of case sensitivity), producing CWE-863 (incorrect authorization)
RelationshipDistinct from CVE-2026-52810, a different defect in the same handler area
Root causeinternal/route/repo/http.go: pull-vs-push classifier is case-sensitive and defaults to pull, while the request dispatcher lowercases the path before routing
ImpactAuthenticated read-only user can push to a private repository: create, update, or delete refs

The VI:H reflects total integrity loss on the vulnerable system because a reader can rewrite the repository, and SI:L captures the supply-chain consequence since injected commits are trusted by every downstream consumer that pulls from it.

Correction before publication: an early analysis claimed unauthenticated push against public repos, but that claim is wrong, and the real issue is the authenticated read-to-write escalation on private repos, encoded as PR:L in the vector.


Mitigation guidance

Any Gogs 0.14.3 instance, or any newer build that has not normalised the Git action verb, should treat every read-only grant on a private repo as equivalent to write until patched, because the read/write privilege boundary collapses for any principal that can authenticate with read access, including direct collaborators and org team members.

Detection: behavioural test

Issue two requests as a read-only collaborator against a private repo you control:

A (canonical): GET /{owner}/{repo}/info/refs?service=git-receive-pack
B (variant):   GET /{owner}/{repo}/info/Refs?service=git-receive-pack

The only difference is the capital R in request B.

ABVerdict
403200 (serves git-receive-pack advertisement)Vulnerable
403403Patched or not affected

Request A is the control that confirms the account genuinely has no write access, and a 200 on request B means the server misclassified a push request as a read and served the push advertisement to a user who should never have seen it.

Remediation

Primary: upgrade to a build that normalises the action verb before the pull/push decision.

Interim (reverse-proxy): reject any Git Smart HTTP request whose path segment after {owner}/{repo} is not fully lowercase, since the canonical Git client never varies case on these verbs, which means this rule blocks the exploit with zero impact on legitimate traffic.


Target selection

Gogs met the criteria because it is a network-reachable service where the authorization handler faces the internet directly rather than sitting behind a CLI or library boundary, the Go source and both the vulnerable and fixed trees are public with no licence or account required, and the defect sits in the authorization path of a service whose entire purpose is enforcing read vs. write on source code, so a read-to-write escalation in a Git forge is effectively supply-chain write access to everything downstream.

Prior art check

All channels checked 2026-11-05, the CVE publication date; treat the date as part of the claim since this table decays fast.

ChannelResult
GitHub (code + repo search for CVE ID, gitHTTPIsPull)No public exploit or writeup
nuclei-templates http/cves/2026/Absent
Metasploit framework masterNothing
Exploit-DB / SploitusNo hits
CISA KEVNot listed
General web search (excluding aggregator noise)NVD, MITRE, vendor advisory only — no technical analysis

No public exploit was found, which is weak evidence of novelty rather than strong evidence; we found the primary records and nothing explaining the mechanism, and that gap is what this post fills.


Diff analysis

The bug was introduced by a security fix, which is why the right comparison is 0.14.2 against 0.14.3.

In 0.14.2 the pull-vs-push classifier used a positive test that identified known read operations and required write access for everything else, a fail-closed design where unrecognised or oddly-shaped requests default to requiring write permission. The 0.14.3 refactor rewrote the same decision as a negative test against the push verb, so anything the classifier does not recognise as the exact canonical push string is treated as a pull that only needs read access — a fail-open default. The refactor did fix a real pre-existing issue on the canonical path, but it opened a case-sensitivity side channel in the process.

Both trees are public on the Gogs GitHub repository, and the CVE record itself points at internal/route/repo/http.go around lines 64-68 for the classifier and 415-418 for the dispatcher, but the advisory never explains the interaction between the two functions, which is where the actual vulnerability lives.


Root cause

The classifier: case-sensitive, defaults to pull

The gitHTTPIsPull function in internal/route/repo/http.go receives the raw URL suffix after /{owner}/{repo}/ with its original capitalisation preserved, and both of its return paths use case-sensitive string comparison with a default of true (pull), which means any action string that is not byte-for-byte equal to the canonical push verb gets classified as a read:

func gitHTTPIsPull(c *macaron.Context, action string) bool {
    if action == "info/refs" {
        return c.Query("service") != "git-receive-pack"
    }
    return action != "git-receive-pack"
}

The authorization handler HTTPContexter consumes that boolean directly, and when gitHTTPIsPull returns true it enforces AccessModeRead, which a read-only collaborator passes cleanly:

mode := database.AccessModeWrite
if isPull {
    mode = database.AccessModeRead
}
if !database.Handle.Permissions().Authorize(
    c.Req.Context(), authUser.ID, repo.ID, mode, ...) {
    askCredentials(c, http.StatusForbidden, "User permission denied")
    return
}

The dispatcher: case-insensitive

The router matches on strings.ToLower(c.Req.URL.Path), so a POST to git-receive-Pack with a capital P lowercases to git-receive-pack and routes to the receive-pack handler that accepts a packfile and updates refs. The authorization layer, which looked at the same request a moment earlier, saw a string that was not equal to the canonical verb and authorized it as a read, which is the entire desync.

The third guard: why the exploit keeps most of the verb lowercase

A pre-authentication guard rejects actions that do not look like Git operations, and it requires the lowercase substring git- or info/ to be present via case-sensitive strings.Contains:

if !strings.Contains(action, "git-") &&
    !strings.Contains(action, "info/") &&
    !strings.Contains(action, "HEAD") &&
    !strings.Contains(action, "objects/") {
    c.Error(http.StatusBadRequest,
        fmt.Sprintf("Unrecognized action %q", action))
    return
}

A fully uppercased verb like GIT-RECEIVE-PACK fails this guard with a 400 before authorization even runs, so the naive approach of capitalising the whole verb is a dead end. The working request has to thread three constraints simultaneously: it must contain the lowercase substring git- or info/ to pass the guard, it must differ from the canonical verb by at least one character so the classifier treats it as a pull, and it must lowercase to the real route so the dispatcher sends it to receive-pack. The way to satisfy all three is to keep the prefix lowercase and capitalise only a later character:

GET  /{owner}/{repo}/info/Refs?service=git-receive-pack
POST /{owner}/{repo}/git-receive-Pack

The canonical lowercase forms are correctly denied to a read-only user, while the variants above pass the guard, get misclassified as reads, and dispatch as writes.


Confirmation: differential test

The confirmation was a differential against two local builds from the same source tree, because the claim is not “this code looks wrong” but “this exact request succeeds on the stock build and fails on the patched one”:

gogs          stock 0.14.3                              vulnerable   -> :3000
gogs-patched  0.14.3 + one-line case normalisation      patched      -> :3001

The one-line fix adds strings.ToLower(action) at the top of gitHTTPIsPull:

func gitHTTPIsPull(c *macaron.Context, action string) bool {
    action = strings.ToLower(action)
    if action == "info/refs" {
        return c.Query("service") != "git-receive-pack"
    }
    return action != "git-receive-pack"
}

The scenario uses a private repo alice/proj with a second account mallory added as a read-only collaborator.

Results

Request (as mallory)Stock :3000Patched :3001
GET info/refs?service=git-receive-pack (canonical)403403
GET info/Refs?service=git-receive-pack (variant)200, push advertisement served403
POST git-receive-Pack → create refs/heads/x200, unpack ok, ok refs/heads/x, ref written to diskDenied (no advertisement)

The canonical 403 on both builds proves mallory has no write access, and the 200 on the variant against stock 0.14.3 is the vulnerability: the server handed a read-only user the push advertisement, accepted the push, and the new ref was present on disk afterwards. The patched build refuses the same request with 403 because after normalisation the classifier and dispatcher agree it is a write. Same request, two builds, opposite outcome, one line of difference.


Proof of concept

The verification tooling is a single Python script that drives the Smart HTTP protocol directly rather than shelling out to git, giving byte-level control over the request so the case variation is applied exactly where it matters.

Check mode (safe, read-only)

Issues both GET advertisements and compares status codes, writes nothing:

RECV = "git-receive-pack"
VARIANT_INFO = "info/Refs"  # canonical: "info/refs"

def advert_url(base, repo, segment):
    return f"{base}/{repo}/{segment}?service={RECV}"

def do_check(base, repo):
    canon_code, _      = http(advert_url(base, repo, "info/refs"))
    var_code, var_body  = http(advert_url(base, repo, VARIANT_INFO))
    served = var_code == 200 and b"service=git-receive-pack" in var_body
    if canon_code in (401, 403) and served:
        print("VULNERABLE: read-only user reached the push advertisement.")

Push mode

The push constructs a POST to the case-variant endpoint with a well-formed receive-pack command; the ref-update framing and packfile construction are withheld since publishing them saves an attacker the only non-trivial step and gives a defender nothing the check mode does not already provide.


References


Author: Khalid Halloumi