# Patch Analysis — CVE-2026-52813

## Reference

- **Fix commit:** `f6acd467305943aae8403cbac81f0118dd1235d7`
- **PR:** [#8334](https://github.com/gogs/gogs/pull/8334) — "security: reject
  path traversal in owner and repository names"
- **Advisory:** GHSA-c39w-43gm-34h5
- **Released in:** Gogs v0.14.3 (commit `3ba8aca90e17e5410b7e8b227c9f29256ac3e875`)
- **Vulnerable ref tested:** v0.14.2 (`5dcb6c64bdf61e38dbdbb941c1d69789c560d0fb`)
- **Fixed ref tested:** v0.14.3 (`3ba8aca90e17e5410b7e8b227c9f29256ac3e875`)

## What the fix changes (files, functions, logic)

The fix commit's stated diff touches `internal/repox/repox.go` and
`internal/route/api/v1/org.go`; in the released v0.14.3 tree the equivalent
hardened code lives in `internal/repoutil/repoutil.go` and
`internal/route/api/v1/org/org.go` (the codebase was refactored between the fix
commit and the release tag). The net behavior is:

### 1. Path-level hardening — `internal/repoutil/repoutil.go`

**Before (v0.14.2):**
```go
func UserPath(user string) string {
    return filepath.Join(conf.Repository.Root, strings.ToLower(user))
}
func RepositoryPath(owner, repo string) string {
    return filepath.Join(UserPath(owner), strings.ToLower(repo)+".git")
}
```

**After (v0.14.3):**
```go
func UserPath(user string) string {
    // 🚨 SECURITY: Prevent path traversal in user name.
    return filepath.Join(conf.Repository.Root, pathutil.Clean(strings.ToLower(user)))
}
func RepositoryPath(owner, repo string) string {
    // 🚨 SECURITY: Prevent path traversal in repository name.
    return filepath.Join(UserPath(owner), pathutil.Clean(strings.ToLower(repo))+".git")
}
```

`pathutil.Clean` (`internal/pathutil/pathutil.go`):
```go
func Clean(p string) string {
    p = strings.ReplaceAll(p, `\`, "/")
    return strings.Trim(path.Clean("/"+p), "/")
}
```
Prepending `/` anchors `path.Clean` so `..` is absorbed (it cannot escape above
the synthetic root), and the trailing `strings.Trim("/", …)` yields a clean
relative path. `filepath.Join(ROOT, cleaned)` therefore always stays under
`ROOT`. Verified against the `pathutil` test table (`../../../readme.txt` →
`readme.txt`, `a/../../../readme.txt` → `readme.txt`, Windows backslash cases,
etc.).

### 2. Boundary-level validation — v1 organization API

`api.CreateOrgOption` (in the external `go-gogs-client` module) declares only
`UserName string \`json:"username" binding:"Required"\`` — **no `AlphaDashDot`**
— because binding tags cannot be enforced on this branch for an external-module
struct. The fix therefore validates **inline** in `CreateOrgForUser`
(`internal/route/api/v1/org/org.go`), which is reached by **both** v1
org-creation handlers:
```go
// internal/route/api/v1/org/org.go (v0.14.3)
func CreateOrgForUser(c *context.APIContext, apiForm api.CreateOrgOption, user *database.User) {
    ...
    if apiForm.UserName == "" ||
        len(apiForm.UserName) > 35 ||
        binding.AlphaDashDotPattern.MatchString(apiForm.UserName) {
        c.ErrorStatus(http.StatusUnprocessableEntity, errors.Newf("invalid org name: %q", apiForm.UserName))
        return
    }
    ...
}
```
`AlphaDashDotPattern = regexp.MustCompile(`[^\d\w-_\.]`)` (go-macaron/binding) —
matches any character outside `[A-Za-z0-9_.-]`, so `/`, `\`, spaces, etc. are
rejected. Both `admin.CreateOrg` (`POST /api/v1/admin/users/:user/orgs`) and
`org.CreateMyOrg` (`POST /api/v1/user/orgs`) route through `CreateOrgForUser`, so
both are covered.

## What invariant the fix relies on

1. **Owner/repository filesystem paths are computed only through
   `repoutil.UserPath` / `repoutil.RepositoryPath`** (now cleaned), **or** through
   callers that pass names already validated by `AlphaDashDot`.
2. **Organization names from the v1 API always flow through `CreateOrgForUser`**
   (inline `AlphaDashDot`).

## What code paths / inputs the fix covers

- All callers of `repoutil.UserPath` / `repoutil.RepositoryPath` (organization
  creation, repository creation via the hardened path, user creation/rename,
  pull/fork base/head path computation, Git-HTTP `getGitRepoPath` (already
  separately guarded by the older #7022 `pathutil.Clean` check), backup).
- v1 API org creation (admin + non-admin) via inline `AlphaDashDot`.
- Web org creation (`form.CreateOrg` / `form.CreateOrg` `Name` already
  `AlphaDashDot;MaxSize(35)`).

## What the fix does NOT cover (gaps)

### Gap A — `database.RepoPath` / `database.WikiPath` do not clean the repo name

`internal/database/repo.go:1356` (*Deprecated: Use repoutil.RepositoryPath
instead*) and `internal/database/wiki.go:54`:
```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")
}
```
These clean the **owner** (via `UserPath`) but **not the repo name**. They are
used by `MigrateRepository` (`repo.go:808-809`, which then runs
`git clone --mirror … <repoPath>`) and by various read/open paths
(`context/repo.go`, `pull.go`, `mirror.go`, `release.go`). The repository name
is therefore protected **only** by the `AlphaDashDot` form validator at the API
boundary — not by path-level cleaning.

**Reachability:** Currently **not exploitable**. Every repository-name entry
point already declares `AlphaDashDot`:
- `form.CreateRepo.RepoName` — `Required;AlphaDashDot;MaxSize(100)`
- `form.MigrateRepo.RepoName` — `Required;AlphaDashDot;MaxSize(100)`
- `form.RepoSetting.RepoName` (rename) — `Required;AlphaDashDot;MaxSize(100)`
- `api.CreateRepoOption.Name` — `Required;AlphaDashDot;MaxSize(100)`

`AlphaDashDot` rejects `/` (and `\`), so a traversal sequence can never reach
`RepoPath`/`WikiPath`. These `AlphaDashDot` tags were already present on v0.14.2,
so the repo-name traversal was **never reachable on either version**. Gap A is a
**defense-in-depth inconsistency**, not a live bypass; it becomes exploitable
only if a future/missed repo-name entry point omits `AlphaDashDot`.

### Gap B — deprecated helpers still present

`database.RepoPath` / `database.WikiPath` are marked deprecated but retained and
actively called (migration, pulls, context). Any new caller that passes an
unvalidated repo name would re-open the path-traversal → RCE chain (the sink
`UpdateLocalCopyBranch` + `Repository.LocalCopyPath()` worktree at
`data/tmp/local-r/<id>` is unchanged on v0.14.3).

## Comparison of behavior before vs. after the fix

| Step | v0.14.2 (before) | v0.14.3 (after) |
|------|------------------|-----------------|
| `POST /api/v1/user/orgs` with `username=../data/tmp/local-r/<id>/nested` (non-admin) | **201** — org created; `UserPath` does not clean → owner dir at `<APP_DATA_PATH>/tmp/local-r/<id>/nested` (outside ROOT, inside worktree) | **422** — `CreateOrgForUser` inline `AlphaDashDot` rejects; even if reached, `UserPath` cleans to `data/tmp/local-r/<id>/nested` (inside ROOT) |
| `POST /api/v1/admin/users/:u/orgs` with traversal `username` (admin) | **201** — same traversal | **422** — `CreateOrgForUser` rejects |
| Create repo under traversal org → bare repo path | `RepositoryPath`/`RepoPath` → outside ROOT, inside worktree | blocked (no traversal org) |
| Plant executable `post-update` via Git smart-HTTP + web-upload sync | materialises into nested bare repo `hooks/` (mode 0755) | n/a (no nested repo) |
| `git receive-pack` on planted bare repo | **RCE** as Gogs user | no execution |
| Repo-name traversal via `database.RepoPath`/`WikiPath` | blocked by `AlphaDashDot` on repo name (not reachable) | blocked by `AlphaDashDot` (not reachable); `repoutil.RepositoryPath` also cleans |

## Target threat-model scope

Gogs' security scope treats authenticated API/Git-HTTP input as untrusted and
protects the filesystem repository root. Path traversal in owner/repo names that
escapes the repository root and overwrites server-side Git hooks is in-scope
(critical, RCE). The fix addresses owner/repo name traversal at the path layer
and org-name traversal at the API boundary. The variant in this analysis
(`POST /api/v1/user/orgs`, non-admin) is within the same trust boundary
(authenticated remote caller) and the same sink; it is covered by the v0.14.3
fix.

## Completeness assessment

- **Owner (org/user) name traversal:** **Fully fixed** at both layers (inline
  `AlphaDashDot` for the v1 API; `pathutil.Clean` in `UserPath` for all
  callers). Both admin and non-admin v1 org-creation endpoints are covered.
- **Repository name traversal via `repoutil.RepositoryPath`:** **Fixed**
  (`pathutil.Clean`).
- **Repository name traversal via `database.RepoPath`/`WikiPath`:** **Not
  fixed at the path layer**, but **not reachable** because `AlphaDashDot`
  blocks `/` at every current entry point. Recommend hardening for defense in
  depth.
- **No true bypass of v0.14.3 found.** The confirmed finding is an
  **alternate, lower-privilege entry point** (`CreateMyOrg`) for the same
  pre-fix root cause; it is blocked on v0.14.3.
