# Variant RCA Report — CVE-2026-52813

## Summary

A **lower-privilege alternate trigger** for CVE-2026-52813 was found and
runtime-verified. The original reproduction created the path-traversal
organization through the **admin-only** endpoint
`POST /api/v1/admin/users/:user/orgs` (`admin.CreateOrg`) using an admin token.
This variant shows that the **identical** root-cause chain (path-traversal
organization name → bare repository written outside the repository ROOT inside
another repository's local worktree → executable `post-update` hook planted via
Git smart-HTTP + web-upload sync → `git-receive-pack` executes the hook as the
Gogs service user → RCE) is reachable through the **non-admin, self-service**
endpoint `POST /api/v1/user/orgs` (`org.CreateMyOrg`, `reqToken()` only) by **any
authenticated user** — confirmed end-to-end with a user whose `is_admin=0`. This
matches the parent RCA's "low-privileged authenticated user" impact claim, which
the admin-based reproduction did not actually exercise. The variant is **not a
bypass**: on the fixed v0.14.3, `CreateOrgForUser` rejects the traversal name
inline (`AlphaDashDot` → HTTP 422) **and** `repoutil.UserPath` neutralises `..`
via `pathutil.Clean`, so no nested repository is created and no execution occurs.
A separate, **non-reachable defense-in-depth gap** (`database.RepoPath` /
`database.WikiPath` not applying `pathutil.Clean` to the repository name) is
documented below but could not be triggered because every repository-name entry
point already enforces `AlphaDashDot` (which blocks `/`).

## Fix Coverage / Assumptions

**Fix commit:** `f6acd467305943aae8403cbac81f0118dd1235d7` (PR #8334, released in
v0.14.3, "security: reject path traversal in owner and repository names").

The fix relies on two layers:

1. **Path-level (defense in depth):** `repoutil.UserPath` and
   `repoutil.RepositoryPath` now wrap the owner/repository name with
   `pathutil.Clean`:
   ```go
   // internal/repoutil/repoutil.go (v0.14.3)
   func UserPath(user string) string {
       return filepath.Join(conf.Repository.Root, pathutil.Clean(strings.ToLower(user)))
   }
   func RepositoryPath(owner, repo string) string {
       return filepath.Join(UserPath(owner), pathutil.Clean(strings.ToLower(repo))+".git")
   }
   ```
   `pathutil.Clean(p) = strings.Trim(path.Clean("/"+p), "/")`, which robustly
   collapses `..` (the leading `/` anchors the clean so `..` is absorbed rather
   than escaping). Any caller of `UserPath`/`RepositoryPath` is therefore
   protected at the filesystem-path layer.

2. **Boundary-level:** The v1 organization-creation API validates the name. The
   JSON option struct `api.CreateOrgOption` lives in the external
   `go-gogs-client` module and **cannot carry `binding` tags on this branch**, so
   the fix enforces validation **inline** in `CreateOrgForUser`
   (`internal/route/api/v1/org/org.go`):
   ```go
   if apiForm.UserName == "" ||
       len(apiForm.UserName) > 35 ||
       binding.AlphaDashDotPattern.MatchString(apiForm.UserName) {
       c.ErrorStatus(http.StatusUnprocessableEntity, ...)
       return
   }
   ```
   `AlphaDashDotPattern = [^\d\w-_\.]` rejects `/`, `\`, `..`-with-slash, etc.

**What the fix covers:** Every owner/organization-name entry point that reaches
`repoutil.UserPath` (organization creation, user creation, user rename) is now
protected at the path layer, and the v1 API org-creation handlers
(`admin.CreateOrg` → `CreateOrgForUser`, `org.CreateMyOrg` → `CreateOrgForUser`)
additionally validate at the boundary.

**What the fix does NOT cover (gap, non-reachable):**
`database.RepoPath` (`internal/database/repo.go:1356`, *Deprecated: Use
repoutil.RepositoryPath instead*) and `database.WikiPath`
(`internal/database/wiki.go:54`) call `repoutil.UserPath(owner)` — which **cleans
the owner** — but do **NOT** apply `pathutil.Clean` to the **repository name**;
they only `strings.ToLower(repoName)`:
```go
func RepoPath(userName, repoName string) string {
    return filepath.Join(repoutil.UserPath(userName), strings.ToLower(repoName)+".git")
}
func WikiPath(userName, repoName string) string {
    return filepath.Join(repoutil.UserPath(userName), strings.ToLower(repoName)+".wiki.git")
}
```
`MigrateRepository` (`internal/database/repo.go:808-809`) uses these unhardened
functions with `opts.Name` and then runs `git clone --mirror … <repoPath>`. The
repository name is therefore protected **only** by the `AlphaDashDot` form-level
validator at the API boundary, not by path-level cleaning. This is a real
defense-in-depth inconsistency, but it is **not currently exploitable**: every
repository-name entry point (`form.CreateRepo.RepoName`,
`form.MigrateRepo.RepoName`, `form.RepoSetting.RepoName`,
`api.CreateRepoOption.Name`) already declares
`binding:"Required;AlphaDashDot;MaxSize(100)"`, and `AlphaDashDot` rejects `/`,
so a traversal sequence can never reach `RepoPath`/`WikiPath`. (Repo names were
already `AlphaDashDot`-validated on v0.14.2 as well, so the repo-name traversal
was never reachable on either version.)

## Variant / Alternate Trigger

**Entry point (variant):** `POST /api/v1/user/orgs` — `org.CreateMyOrg`,
registered as `m.Combo("/user/orgs", reqToken()).Post(bind(api.CreateOrgOption{}), org.CreateMyOrg)`
(`internal/route/api/v1/api.go:382-384`). `reqToken()` requires only a valid
access token — **no admin privilege**.

**Why it reaches the same root cause on v0.14.2:**
- `api.CreateOrgOption.UserName` carries only `binding:"Required"` (no
  `AlphaDashDot`) because the struct lives in the external `go-gogs-client`
  module.
- v0.14.2 `CreateOrgForUser` (`internal/route/api/v1/org/org.go`) performs **no
  inline validation**; it builds `&database.User{Name: apiForm.UserName, …}` and
  calls `database.CreateOrganization(org, c.User)`.
- `database.CreateOrganization` calls `os.MkdirAll(repoutil.UserPath(org.Name))`,
  and v0.14.2 `repoutil.UserPath` does **not** `pathutil.Clean` — so an org name
  such as `../data/tmp/local-r/<wid>/nested` is accepted (HTTP 201) and the owner
  directory is created at `<APP_DATA_PATH>/tmp/local-r/<wid>/nested`, **outside**
  the configured repository ROOT and **inside** the local worktree of the
  attacker's own `writer` repository.
- The attacker then creates repository `rce` under that org
  (`POST /api/v1/org/<enc>/repos`, `CreateOrgRepo`, ownership-based — non-admin
  OK), so the bare repo is written at
  `<APP_DATA_PATH>/tmp/local-r/<wid>/nested/rce.git`. From here the chain is
  identical to the original reproduction: an executable `post-update` hook (mode
  `100755`) is pushed to `writer` through Gogs Git smart-HTTP; a Gogs web-upload
  sync (`UpdateLocalCopyBranch` → `git fetch` + `reset --hard`) materialises the
  tracked `nested/rce.git/hooks/post-update` into the nested bare repo's `hooks/`
  (mode `0755`); a `git receive-pack` on the planted bare repo runs the attacker's
  hook as the Gogs service user → **RCE**.

**Contrast with the original reproduction:** the original used
`POST /api/v1/admin/users/:user/orgs` (`admin.CreateOrg`, requires admin) with an
**admin** token. This variant uses `POST /api/v1/user/orgs` (`org.CreateMyOrg`,
`reqToken()` only) with a **non-admin** user (`is_admin=0`), proving the full RCE
chain is achievable by any authenticated user.

**Why it is NOT a bypass on v0.14.3:** v0.14.3 `CreateOrgForUser` adds the inline
`AlphaDashDot` check above, so the traversal name is rejected with HTTP 422
before `CreateOrganization` is reached; even if it were reached,
`repoutil.UserPath` would `pathutil.Clean` the name. The same variant run on
v0.14.3 produced `org_create_status=422`, no nested repository, and no execution.

**Code paths involved:**
- `internal/route/api/v1/api.go:382-384` (route registration, non-admin)
- `internal/route/api/v1/org/org.go` `CreateMyOrg` / `CreateOrgForUser`
- `internal/database/org.go` `CreateOrganization` → `os.MkdirAll(repoutil.UserPath(org.Name))`
- `internal/repoutil/repoutil.go` `UserPath` (v0.14.2: no clean; v0.14.3: `pathutil.Clean`)
- `internal/database/repo.go` `CreateRepository` + `RepositoryPath`/`RepoPath`
- `internal/database/repo_editor.go` `UpdateLocalCopyBranch` (worktree sync that materialises the hook)
- `internal/database/repo.go:648` `Repository.LocalCopyPath()` → `data/tmp/local-r/<id>` (worktree sink, used on both v0.14.2 and v0.14.3)

## Impact

- **Package/component affected (variant entry point):**
  `internal/route/api/v1/org/org.go` (`CreateMyOrg`/`CreateOrgForUser`);
  `internal/route/api/v1/api.go` (`POST /api/v1/user/orgs` route); same sink as
  the original CVE (`repoutil.UserPath`, `database.CreateOrganization`,
  `UpdateLocalCopyBranch`).
- **Affected versions (as tested):** Gogs v0.14.2 (commit
  `5dcb6c64bdf61e38dbdbb941c1d69789c560d0fb`) — variant reproduces (RCE as
  non-admin). Gogs v0.14.3 (commit `3ba8aca90e17e5410b7e8b227c9f29256ac3e875`)
  — variant blocked (422).
- **Risk level:** Critical. The variant **lowers the required privilege** from
  admin to any authenticated user, confirming the vulnerability is remotely
  exploitable by a low-privileged account through a documented self-service API.

## Impact Parity

- **Disclosed/claimed maximum impact (parent):** Remote code execution through
  Git hooks; "low-privileged authenticated user" (GHSA-c39w-43gm-34h5).
- **Reproduced impact from this variant run:** Full chain demonstrated against a
  real Gogs v0.14.2 server using a **non-admin** user (`is_admin=0`): traversal
  org accepted via `POST /api/v1/user/orgs` (HTTP 201); nested bare repo written
  **outside** the repository ROOT, inside another repo's local worktree
  (`data/tmp/local-r/<wid>`); executable `post-update` hook (mode `0755`) planted
  via Git smart-HTTP + web-upload sync; `git receive-pack` on the planted bare
  repo executed the attacker's hook as the Gogs user (`uid=1000`), writing the
  marker — i.e. arbitrary code execution. Fixed v0.14.3 rejects the traversal
  org (HTTP 422), creates no nested repo, and produces no execution.
- **Parity:** `full` — code execution as the Gogs service user was demonstrated
  on the affected version via the lower-privilege entry point, and is absent on
  the fixed version.
- **Not demonstrated:** Persistence / lateral movement beyond the marker write;
  the hook payload is a deterministic marker-writing script, identical in
  capability to the original reproduction.

## Root Cause

The underlying bug is unchanged from the parent CVE: `repoutil.UserPath` (and
`RepositoryPath`) joined the owner/repo name under the repository root with
`filepath.Join` **without neutralising `..`**, and the v1 organization-creation
API accepted organization names containing `/` (because `api.CreateOrgOption`
has no `AlphaDashDot` and v0.14.2 `CreateOrgForUser` performed no inline
validation). The same root cause is reachable from a **different, lower-privilege
entry point** — `POST /api/v1/user/orgs` (`CreateMyOrg`) — because that handler
shares the unvalidated `CreateOrgForUser` → `CreateOrganization` → `UserPath`
path. The fix (commit `f6acd467305943aae8403cbac81f0118dd1235d7`) closes both the
admin and non-admin org-creation entry points by adding inline `AlphaDashDot`
validation in `CreateOrgForUser` and `pathutil.Clean` in `UserPath`.

The variant therefore demonstrates that the **pre-fix** attack surface was
broader than the single admin endpoint the original reproduction exercised, and
that the fix correctly covers the non-admin endpoint as well.

## Reproduction Steps

1. Self-contained script: `bundle/vuln_variant/reproduction_steps.sh`.
2. What the script does (all against the **real** Gogs server / API / Git
   smart-HTTP; it reuses the already-built `v0.14.2` and `v0.14.3` binaries, then
   runs the chain once per version):
   - Starts a real Gogs instance (SQLite) on 127.0.0.1, creates a **non-admin**
     user (`is_admin=0`) and an API token (inserted directly into `access_token`).
   - Creates a normal repository `writer` (`POST /api/v1/user/repos`), reads its
     internal id `<wid>`, pushes README, and performs a Gogs web upload so Gogs
     materialises `writer`'s local worktree at `<APP_DATA_PATH>/tmp/local-r/<wid>`.
   - Plants the tracked executable file
     `nested/rce.git/hooks/post-update` (mode `100755`, marker-writing script) and
     pushes it to `writer` via Gogs Git smart-HTTP.
   - **VARIANT STEP:** creates the traversal organization
     `../data/tmp/local-r/<wid>/nested` via the **non-admin** endpoint
     `POST /api/v1/user/orgs` (`CreateMyOrg`), then creates repository `rce`
     under it via `POST /api/v1/org/<enc>/repos` (`CreateOrgRepo`).
   - Performs a second Gogs web upload on `writer`; `UpdateLocalCopyBranch`
     materialises the attacker's executable `post-update` into the nested bare
     repo's `hooks/`.
   - Triggers the hook with a real `git push` (`git receive-pack`) onto the
     planted bare repo; the attacker's `post-update` runs as the Gogs user and
     writes `bundle/vuln_variant/rce_marker_<role>.txt`.
   - For the fixed v0.14.3 control: the same chain; organization creation is
     rejected (HTTP 422), no nested repo is created, and no marker is produced.
3. Expected evidence of reproduction:
   - `bundle/vuln_variant/variant_proof_summary.txt`:
     `vuln_nonadmin_rce=1`, `fixed_blocked=1`, `bypass=no`,
     `observed_impact=code_execution`.
   - `bundle/vuln_variant/rce_marker_vuln.txt`: hook output
     `PRUVA_GOGS_RCE_EXECUTED`, `variant=nonadmin role=vuln`,
     `user=vscode uid=1000 gid=1000`,
     `cwd=…/data/tmp/local-r/1/nested/rce.git`.
   - `bundle/logs/vv_state_vuln.log`: `is_admin=0`,
     `org_create_endpoint=POST /api/v1/user/orgs (CreateMyOrg, NON-ADMIN)`,
     `org_create_status=201 repo_create_status=201`, `nested_repo_exists=yes
     (outside …/repositories)`, `hook_planted=yes`, `rce_triggered=yes`.
   - `bundle/logs/vv_state_fixed.log`: `is_admin=0`,
     `org_create_status=422`, `nested_repo_exists=no`, `rce_triggered=no`.
   - `bundle/vuln_variant/runtime_manifest.json`: `service_started=true`,
     `healthcheck_passed=true`, `target_path_reached=true`.

## Evidence

- `bundle/logs/vuln_variant_steps.log` — full variant run transcript (both runs).
- `bundle/vuln_variant/variant_proof_summary.txt` — verdict counters.
- `bundle/vuln_variant/runtime_manifest.json` — runtime evidence manifest.
- `bundle/vuln_variant/rce_marker_vuln.txt` — proof that the attacker's Git hook
  executed as the Gogs service user via the non-admin entry point.
- `bundle/logs/vv_state_vuln.log`, `bundle/logs/vv_state_fixed.log` — per-version
  state (statuses, `is_admin`, nested-repo path outside ROOT, hook mode,
  marker).
- `bundle/logs/vv_gogs_vuln.log`, `bundle/logs/vv_http_vuln.log`,
  `bundle/logs/vv_git_vuln.log`, `bundle/logs/vv_gogs_fixed.log`,
  `bundle/logs/vv_http_fixed.log`, `bundle/logs/vv_git_fixed.log`,
  `bundle/logs/vv_build_vuln.log`, `bundle/logs/vv_build_fixed.log` — server,
  HTTP, git and build logs.

Key excerpt (vulnerable run, hook execution marker, non-admin):
```
PRUVA_GOGS_RCE_EXECUTED
variant=nonadmin role=vuln
user=vscode uid=1000 gid=1000
cwd=…/data/tmp/local-r/1/nested/rce.git
Wed Jul  1 14:34:42 UTC 2026
```
Key excerpt (state, vulnerable): `is_admin=0 …
org_create_endpoint=POST /api/v1/user/orgs (CreateMyOrg, NON-ADMIN)
org_create_status=201 repo_create_status=201 nested_repo_exists=yes (outside …/repositories)
hook_planted=yes rce_triggered=yes`.
Key excerpt (state, fixed): `is_admin=0 … org_create_status=422
repo_create_status=500 nested_repo_exists=no … rce_triggered=no`.
Nested hook (vulnerable): `…/data/tmp/local-r/1/nested/rce.git/hooks/post-update`
mode `-rwxr-xr-x` (attacker content, executable).

Environment: Gogs built from source (Go 1.25, tags `sqlite cert`) at `v0.14.2`
(commit `5dcb6c64bdf61e38dbdbb941c1d69789c560d0fb`) and `v0.14.3` (commit
`3ba8aca90e17e5410b7e8b227c9f29256ac3e875`); SQLite backend; repository ROOT and
APP_DATA_PATH isolated per attempt under
`bundle/artifacts/gogs-cve-2026-52813/vv-run-<role>/`.

## Recommendations / Next Steps

- **Upgrade** to Gogs 0.14.3+ (already covers the non-admin `CreateMyOrg` entry
  point via inline `AlphaDashDot` + `pathutil.Clean`).
- **Close the defense-in-depth gap:** apply `pathutil.Clean` to the repository
  name inside `database.RepoPath` and `database.WikiPath` (or replace their
  remaining call sites with the already-hardened `repoutil.RepositoryPath` and a
  cleaned wiki equivalent), so that repository-name path computation does not
  rely solely on the `AlphaDashDot` form validator. This ensures a future (or
  missed) repository-name entry point that omits `AlphaDashDot` cannot re-open
  the same path-traversal → RCE chain.
- **Remove or gate the deprecated `database.RepoPath`/`WikiPath`** to prevent new
  callers from bypassing the hardened `repoutil.RepositoryPath`.
- Defense-in-depth: validate owner/repository names against a strict allow-list
  (`[A-Za-z0-9._-]+`) at a single shared boundary, and refuse to create a
  repository whose resolved path escapes the repository root or overlaps another
  repo's local worktree.
- Keep the `Repository.LocalCopyPath()` worktree sink
  (`data/tmp/local-r/<id>`) on a path that cannot be reached from the repository
  ROOT by a cleaned relative name (e.g. separate volumes).

## Additional Notes

- **Bypass vs. alternate trigger:** This is an **alternate trigger** (a
  different, lower-privilege entry point for the same root cause), **not a
  bypass**: it reproduces on the vulnerable v0.14.2 and is blocked on the fixed
  v0.14.3. Per the stage exit-code convention the script exits `1` (variant only
  on the vulnerable version).
- **Idempotency:** `run_one` removes its run directory and marker at the start of
  each attempt. Verified by running `reproduction_steps.sh` twice consecutively —
  both runs produced identical results (v0.14.2 non-admin RCE confirmed,
  v0.14.3 blocked 422, exit 1).
- **Repository state:** the script reuses the pre-built binaries (no re-checkout
  during the run) and restores the cache repo to the fixed ref
  (`3ba8aca90…`, v0.14.3) at the end, so the coding stage is not disrupted.
- **Non-reachable gap:** the `database.RepoPath`/`WikiPath` repo-name cleaning
  gap was analyzed but could not be triggered at runtime because `AlphaDashDot`
  blocks `/` at every repository-name entry point on both versions; it is
  documented as a hardening recommendation, not claimed as a runtime variant.
- **Local-copy path note:** the actual worktree sink is `data/tmp/local-r/<id>`
  (`Repository.LocalCopyPath()`, `internal/database/repo.go:648`, used by
  `UpdateLocalCopyBranch` on both versions). `repoutil.RepositoryLocalPath`
  (`data/tmp/local-repo/<id>`) is a separate, unused-on-this-path helper and was
  not the target.
