GHSA-8P9H-49RC-QGXJ
Vulnerability from github – Published: 2026-07-21 21:02 – Updated: 2026-07-21 21:02Repository Visibility Manipulation via Git Push Options
| Field | Value |
|---|---|
| Affected File | routers/private/hook_post_receive.go |
| Affected Function | HookPostReceive() |
| Affected Lines | 173–225 |
| Prerequisite | Attacker must have owner-level or admin collaborator access to the target repository |
Description
Gitea's post-receive git hook handler processes git push options — key-value pairs transmitted by a client during git push using the -o flag. Two undocumented push options, repo.private and repo.template, allow any user with repository owner or admin-collaborator access to toggle the visibility (private/public) and template status of a repository as a side effect of a normal git push.
This capability was originally intended solely for the "push-to-create" feature (automatically creating a repo on first push). However, the options are processed without restriction on already-existing repositories, and — critically — the visibility change bypasses every control that a proper settings change would trigger:
- No entry written to the repository's audit/activity log
- No webhook event fired (
repositoryevent withvisibility_changedaction) - No org-level notification to owners
- No team permission re-calculation
- No email alert to watchers
- The database update uses
UpdateRepositoryColsNoAutoTime, which also suppresses theupdated_attimestamp change
Vulnerable Code
routers/private/hook_post_receive.go:173–225
isPrivate := opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate) // "repo.private"
isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate) // "repo.template"
if isPrivate.Has() || isTemplate.Has() {
// ... loads repo and verifies pusher is owner or admin ...
if !perm.IsOwner() && !perm.IsAdmin() {
ctx.JSON(http.StatusNotFound, ...)
return
}
// FIXME: these options are not quite right, for example: changing visibility
// should do more works than just setting the is_private flag
// These options should only be used for "push-to-create"
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
// TODO: it needs to do more work
repo.IsPrivate = isPrivate.Value()
repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private")
// ^^^ bypasses updated_at timestamp, audit trail suppressed
}
if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() {
repo.IsTemplate = isTemplate.Value()
repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template")
}
}
The push option constants are defined in modules/private/pushoptions.go:18–19:
GitPushOptionRepoPrivate = "repo.private"
GitPushOptionRepoTemplate = "repo.template"
Attack Scenario
Scenario A — Insider threat / rogue admin collaborator
An organization grants a contractor repo admin access to contribute to a private repository containing proprietary source code. The contractor, before their access is revoked, makes a private repo public for several minutes — long enough to clone, archive, or index the content — then makes it private again. The action leaves no audit trail distinguishable from a normal git push.
Scenario B — Supply-chain template poisoning
A repository marked as a template is used by CI/CD pipelines to generate new project repositories. An admin collaborator uses repo.template=false to silently remove the template designation, then makes changes to the repo's content, re-marks it as a template with repo.template=true, and waits for downstream consumers to regenerate projects from the now-backdoored template. The updated_at timestamp is unchanged due to UpdateRepositoryColsNoAutoTime, making diff-detection harder.
Step-by-Step Reproduction
Prerequisites:
- A Gitea user account with either owner or admin-collaborator access to a private repository
- git client with push access to the repository
Step 1 — Confirm the target repository is private
Step 2 — Clone the repository
git clone http://USER:PASSWORD@<gitea-host>/OWNER/REPO.git /tmp/target-repo
cd /tmp/target-repo
Step 3 — Make any commit (the push option rides on a real push)
echo "$(date)" >> .gitkeep
git add .gitkeep
git commit -m "routine update"
Step 4 — Execute the exploit push
# Make the repository public
git push http://USER:PASSWORD@<gitea-host>/OWNER/REPO.git main \
-o repo.private=false
# The push completes with a normal success message:
# remote: Processed 1 references in total
# To http://<gitea-host>/OWNER/REPO.git
# abc1234..def5678 main -> main
Step 5 — Verify the repository is now public
Step 6 — Restore and cover tracks
Re-make it private in the same session, leaving no visible audit trail
The repository activity feed shows only two normal push events. The visibility change is invisible.
Verification: confirm no activity log entry
Impact Details
| Impact | Description |
|---|---|
| Data exfiltration | Private source code, CI/CD secrets in plain-text files, environment configs become publicly cloneable for the window the repo is public |
| No audit trail | UpdateRepositoryColsNoAutoTime suppresses the updated_at change; no activity log entry; no webhook; no notification |
| Supply chain | Combined with repo.template=true/false, an attacker can silently rotate repository template status, affecting all downstream repositories that generate from this template |
| Scope | Affects all repos where the attacker has admin-collaborator access — not only repos they own |
Recommended Fix
Option 1 (preferred) — Remove the options from post-receive hook entirely. The repo.private and repo.template push options were designed for the push-to-create flow and have no legitimate use on existing repositories. They should be gated with:
// routers/private/hook_post_receive.go
if isPrivate.Has() || isTemplate.Has() {
if !wasEmpty {
// repo already existed — refuse these options on established repos
log.Warn("Repo push options repo.private/repo.template ignored for existing repo %s", repoName)
// do not process
} else {
// original push-to-create path only
...
}
}
Option 2 — Route through the full visibility-change service so that audit events, webhooks, and team re-syncs are triggered:
// Instead of the raw UpdateRepositoryColsNoAutoTime call:
if err := repo_service.UpdateRepositoryVisibility(ctx, repo, isPrivate.Value()); err != nil {
...
}
Where UpdateRepositoryVisibility fires the repository webhook event and writes an activity log entry.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "code.gitea.io/gitea"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.27.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-58437"
],
"database_specific": {
"cwe_ids": [
"CWE-284"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-21T21:02:44Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Repository Visibility Manipulation via Git Push Options\n\n| Field | Value |\n|-------|-------|\n| **Affected File** | `routers/private/hook_post_receive.go` |\n| **Affected Function** | `HookPostReceive()` |\n| **Affected Lines** | 173\u2013225 |\n| **Prerequisite** | Attacker must have owner-level or admin collaborator access to the target repository |\n\n---\n\n#### Description\n\nGitea\u0027s post-receive git hook handler processes git push options \u2014 key-value pairs transmitted by a client during `git push` using the `-o` flag. Two undocumented push options, `repo.private` and `repo.template`, allow any user with repository owner or admin-collaborator access to toggle the visibility (`private/public`) and template status of a repository as a side effect of a normal git push.\n\nThis capability was originally intended solely for the \"push-to-create\" feature (automatically creating a repo on first push). However, the options are processed without restriction on already-existing repositories, and \u2014 critically \u2014 the visibility change bypasses every control that a proper settings change would trigger:\n\n- No entry written to the repository\u0027s audit/activity log\n- No webhook event fired (`repository` event with `visibility_changed` action)\n- No org-level notification to owners\n- No team permission re-calculation\n- No email alert to watchers\n- The database update uses `UpdateRepositoryColsNoAutoTime`, which also suppresses the `updated_at` timestamp change\n\n\n---\n\n#### Vulnerable Code\n\n**`routers/private/hook_post_receive.go:173\u2013225`**\n\n```go\nisPrivate := opts.GitPushOptions.Bool(private.GitPushOptionRepoPrivate) // \"repo.private\"\nisTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate) // \"repo.template\"\n\nif isPrivate.Has() || isTemplate.Has() {\n // ... loads repo and verifies pusher is owner or admin ...\n if !perm.IsOwner() \u0026\u0026 !perm.IsAdmin() {\n ctx.JSON(http.StatusNotFound, ...)\n return\n }\n\n // FIXME: these options are not quite right, for example: changing visibility\n // should do more works than just setting the is_private flag\n // These options should only be used for \"push-to-create\"\n if isPrivate.Has() \u0026\u0026 repo.IsPrivate != isPrivate.Value() {\n // TODO: it needs to do more work\n repo.IsPrivate = isPrivate.Value()\n repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, \"is_private\")\n // ^^^ bypasses updated_at timestamp, audit trail suppressed\n }\n if isTemplate.Has() \u0026\u0026 repo.IsTemplate != isTemplate.Value() {\n repo.IsTemplate = isTemplate.Value()\n repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, \"is_template\")\n }\n}\n```\n\nThe push option constants are defined in `modules/private/pushoptions.go:18\u201319`:\n\n```go\nGitPushOptionRepoPrivate = \"repo.private\"\nGitPushOptionRepoTemplate = \"repo.template\"\n```\n\n---\n\n#### Attack Scenario\n\n**Scenario A \u2014 Insider threat / rogue admin collaborator**\n\nAn organization grants a contractor repo admin access to contribute to a private repository containing proprietary source code. The contractor, before their access is revoked, makes a private repo public for several minutes \u2014 long enough to clone, archive, or index the content \u2014 then makes it private again. The action leaves no audit trail distinguishable from a normal git push.\n\n**Scenario B \u2014 Supply-chain template poisoning**\n\nA repository marked as a template is used by CI/CD pipelines to generate new project repositories. An admin collaborator uses `repo.template=false` to silently remove the template designation, then makes changes to the repo\u0027s content, re-marks it as a template with `repo.template=true`, and waits for downstream consumers to regenerate projects from the now-backdoored template. The `updated_at` timestamp is unchanged due to `UpdateRepositoryColsNoAutoTime`, making diff-detection harder.\n\n---\n\n#### Step-by-Step Reproduction\n\n**Prerequisites:**\n- A Gitea user account with either owner or admin-collaborator access to a private repository\n- `git` client with push access to the repository\n\n---\n\n**Step 1 \u2014 Confirm the target repository is private**\n\n---\n\n**Step 2 \u2014 Clone the repository**\n\n```bash\ngit clone http://USER:PASSWORD@\u003cgitea-host\u003e/OWNER/REPO.git /tmp/target-repo\ncd /tmp/target-repo\n```\n\n---\n\n**Step 3 \u2014 Make any commit** *(the push option rides on a real push)*\n\n```bash\necho \"$(date)\" \u003e\u003e .gitkeep\ngit add .gitkeep\ngit commit -m \"routine update\"\n```\n\n---\n\n**Step 4 \u2014 Execute the exploit push**\n\n```bash\n# Make the repository public\ngit push http://USER:PASSWORD@\u003cgitea-host\u003e/OWNER/REPO.git main \\\n -o repo.private=false\n\n# The push completes with a normal success message:\n# remote: Processed 1 references in total\n# To http://\u003cgitea-host\u003e/OWNER/REPO.git\n# abc1234..def5678 main -\u003e main\n```\n\n---\n\n**Step 5 \u2014 Verify the repository is now public**\n\n\n---\n\n**Step 6 \u2014 Restore and cover tracks**\n\nRe-make it private in the same session, leaving no visible audit trail\n\nThe repository activity feed shows only two normal push events. The visibility change is invisible.\n\n**Verification: confirm no activity log entry**\n\n\n---\n\n#### Impact Details\n\n| Impact | Description |\n|--------|-------------|\n| **Data exfiltration** | Private source code, CI/CD secrets in plain-text files, environment configs become publicly cloneable for the window the repo is public |\n| **No audit trail** | `UpdateRepositoryColsNoAutoTime` suppresses the `updated_at` change; no activity log entry; no webhook; no notification |\n| **Supply chain** | Combined with `repo.template=true/false`, an attacker can silently rotate repository template status, affecting all downstream repositories that generate from this template |\n| **Scope** | Affects all repos where the attacker has admin-collaborator access \u2014 not only repos they own |\n\n---\n\n#### Recommended Fix\n\n**Option 1 (preferred) \u2014 Remove the options from post-receive hook entirely.** The `repo.private` and `repo.template` push options were designed for the push-to-create flow and have no legitimate use on existing repositories. They should be gated with:\n\n```go\n// routers/private/hook_post_receive.go\nif isPrivate.Has() || isTemplate.Has() {\n if !wasEmpty {\n // repo already existed \u2014 refuse these options on established repos\n log.Warn(\"Repo push options repo.private/repo.template ignored for existing repo %s\", repoName)\n // do not process\n } else {\n // original push-to-create path only\n ...\n }\n}\n```\n\n**Option 2 \u2014 Route through the full visibility-change service** so that audit events, webhooks, and team re-syncs are triggered:\n\n```go\n// Instead of the raw UpdateRepositoryColsNoAutoTime call:\nif err := repo_service.UpdateRepositoryVisibility(ctx, repo, isPrivate.Value()); err != nil {\n ...\n}\n```\n\nWhere `UpdateRepositoryVisibility` fires the `repository` webhook event and writes an activity log entry.",
"id": "GHSA-8p9h-49rc-qgxj",
"modified": "2026-07-21T21:02:44Z",
"published": "2026-07-21T21:02:44Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/security/advisories/GHSA-8p9h-49rc-qgxj"
},
{
"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:L/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Gitea: Repository Visibility Manipulation via Git Push Options"
}
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.