# One Route The Patch Forgot

**How I used Codex to find CVE-2026-58444 in Gitea**

*A token-scope enforcement bypass on* `GET /{owner}/{repo}` *GHSA-cp3q-vrj2-ghhh*

## I. Introduction

Gitea is an open-source, self-hostable Git service the "GitHub replacement, but you own the box" pitch. It runs as a single Go binary that serves the web UI, the REST API, Git-over-HTTP, a package registry, and a CI system (Actions) out of one process and one routing table.

The default install puts everything on one host: web router, API router, Git backend, and the repository data itself. There is no separation between "control plane" and "data plane" the same `chi` router that renders the repo page also hands out tarballs, RSS feeds, and `git-upload-pack`.

![](https://cdn.hashnode.com/uploads/covers/65102b1d5866800ea27ebefa/bb4aff93-96d4-4c6d-bf97-f4fa14cdc3ba.png align="center")

It is one of the most popular self-hosted developer-infrastructure projects on GitHub ([go-gitea/gitea](https://github.com/go-gitea/gitea), ~57k stars, ~7k forks), used by homelabs, universities, air-gapped enterprises, and a lot of small SaaS shops that would rather not put their source on someone else's server. The blast radius of an access-control bug is "every private repository on the instance."

The relevant trust boundary here is not the user it's the **token**. Gitea sells fine-grained personal access tokens: you can mint a token with only `read:issue`, or flag one `public-only`, and hand it to a CI runner or a third-party integration. That promise is the product feature. This bug broke it.

**Vulnerable version:** `go-gitea/gitea` all versions through 1.26.x, including `gitea/gitea:latest` at the time of report. The handler had never enforced token scope.

*   **Advisory:** [GHSA-cp3q-vrj2-ghhh](https://github.com/go-gitea/gitea/security/advisories/GHSA-cp3q-vrj2-ghhh)
    
*   **CVE:** CVE-2026-58444
    
*   **Merged fix:** `f69e15afe7496cc62e96dab244629c69eb31a7bf` (PR #38406), merged into `main` on 2026-07-12; shipped in **1.27.0** the same day.
    

## II. Target selection

I did not pick Gitea because I had a hunch about the repo page. I picked it because of the *shape of its advisory history*.

Gitea publishes its GHSAs, and if you read a year of them at once, one cluster dominates:

| Advisory | Route fixed |
| --- | --- |
| GHSA-cr4g-f395-h25h (CVE-2026-20706) | web archive download `/archive/*` |
| GHSA-3pww-vcvm-3gmj (CVE-2026-27761) | repository RSS/Atom feeds |
| GHSA-cc8w-… | Git smart HTTP |
| GHSA-8629-… | `/user/orgs` |
| GHSA-9r5x-… | OAuth-over-Basic |

Every one of them is the same bug: **a token authenticates on a route, and the route never checks what that token is allowed to do.** Every one of them was fixed by adding a scope guard *to that one handler*.

That is the canonical incomplete-fix shape. The fix narrative was "we added `CheckRepoScopedToken()` to this handler now it's safe." But the vulnerability does not live in the handler. It lives in the **router**, in the decision to let token auth reach a handler at all. Fixing a routing-layer class of bug one handler at a time guarantees you fix exactly the handlers someone reported, and no others.

So the target-selection question was never "is the archive route fixed?" It was:

> **How many routes are in this class, and how many of them got the guard?**

That question is mechanical, exhaustive, and boring which is exactly the kind of question worth handing to an agent.

Selection criteria checked out otherwise too: huge star count, entire server in-tree in Go, no closed-source components, a reproducible nightly container image tagged to an exact commit, and a security team that responds. Everything a variant hunt needs.

## III. Finding

### How I actually drive Codex

The workflow is not "point the AI at the repo and ask if it's vulnerable." That produces confident nonsense. It's three passes, each with a different job.

**Pass 1 build the ground truth, no vulnerability talk.** I cloned the repo at an exact commit and had Codex enumerate a fact table with zero interpretation:

> Read `routers/web/web.go`. For every route registration, output a TSV: method, path pattern, the middleware list verbatim, and the handler symbol. Do not summarize, do not skip routes, do not comment on security. If a route's middleware list is built by a helper, resolve the helper.

The output is not analysis it's a spreadsheet. That matters, because the next question is a `grep` over a table, not a judgement call.

**Pass 2 turn the prior fix into a set-difference query.** Instead of asking "is this safe," I gave the agent the patch as a *premise* and asked it to close the set:

> `context.CheckRepoScopedToken` / `CheckTokenScopes` is the guard that enforces API-token scope on web routes. Two published advisories (GHSA-cr4g, GHSA-3pww) added it to the archive and feed handlers.
> 
> Set **A** = every web route whose middleware includes `webAuth.AllowBasic` or `webAuth.AllowOAuth2`. Set **B** = every handler that calls `CheckRepoScopedToken` or `CheckTokenScopes` on any code path.
> 
> Give me **A \\ B**, and for each row, name the specific repository data the handler renders. Cite file:line for every claim.

That prompt does the whole intellectual job. It's a set difference, so the agent can't get bored and stop at three examples; and "cite file:line" makes every hallucination self-evident in ten seconds.

`A \ B` came back with two interesting rows. One was `actions.GetWorkflowBadge` leaks a build status, probably by design, low value. The other was the repository home page.

**Pass 3 adversarial review of my own finding.** Before touching Docker, I asked the agent to kill it:

> Argue that this is NOT a vulnerability. Find the check I missed. Is there an outer middleware, a `RepoAssignment` side effect, or a template-level filter that already enforces token scope on this path?

It came back with `checkHomeCodeViewable`. Which is a real check and which is exactly the reason the bug is interesting.

### Data flow

```plaintext
GET /{owner}/{repo}
  Authorization: Basic base64(user:<PAT>)

  → routers/web/web.go:1256
      m.Get("/{username}/{reponame}",
            optSignIn,
            webAuth.AllowBasic,            // token auth permitted on this route
            context.RepoAssignment,
            context.RepoRefByType(git.RefTypeBranch),
            repo.SetEditorconfigIfExists,
            repo.Home)

  → services/auth/basic.go
      store.GetData()["IsApiToken"]    = true
      store.GetData()["ApiTokenScope"] = token.Scope   // scope recorded…

  → routers/web/repo/view_home.go:389  func Home(ctx *context.Context)
      checkHomeCodeViewable(ctx)      // checks the USER's permission + code unit enabled
                                      // …scope never read
      renderHomeCode(ctx)             // private README, root tree, description,
                                      // languages, license, latest release
```

The route opts into Basic auth because `go get` needs it: to resolve a private Go module, the toolchain fetches the repo page over HTTP with credentials and reads the `go-import` meta tag. Perfectly reasonable feature. It's also the thing that drags a token into an HTML handler that was written years earlier for browser sessions.

### Guard analysis

`checkHomeCodeViewable` is not a broken function. It correctly answers *"may this* ***user*** *read this repository, and is the code unit enabled?"* For a browser session, that is the entire question.

But once `AllowBasic` is on the route, the permission model has **two independent axes**:

1.  **Who** is the principal does the user have access? → `checkHomeCodeViewable` ✅
    
2.  **What** was the credential authorized to do does the token carry `repository` scope, and is it `public-only`? → nothing ❌
    

Axis 2 has a dedicated helper, `context.CheckRepoScopedToken`, sitting right there in the same package. It is a no-op when `IsApiToken != true`, so it costs browser sessions nothing. On the entire web router, it was called from exactly **four** places:

```plaintext
routers/web/feed/render.go:15        feeds                 ← GHSA-3pww
routers/web/repo/download.go:23      raw / media / archive ← GHSA-cr4g
routers/web/repo/attachment.go:190   attachments / release assets
routers/web/repo/githttp.go:161      git smart HTTP        ← GHSA-cc8w
```

Three of those four call sites exist because someone filed an advisory. That is the tell. `repo.Home` was the remaining token-auth-enabled content route with no guard.

### Core reasoning state it explicitly

*   `AllowBasic` decides *whether a token may authenticate on this route*.
    
*   `checkHomeCodeViewable` decides *whether the user behind the token may read the repo*.
    
*   **Neither one asks what the token itself was scoped to do.**
    

→ Authentication is not authorization, and *user* authorization is not *credential* authorization. When a codebase carries two orthogonal permission axes and enforces them in two different layers, every new opt-in on the outer layer silently re-opens the inner one. The archive fix and the feed fix each closed one route. The class stayed open.

That is the same failure mode as validate-before-canonicalize in a path-traversal bug: the guard is real, it's just answering a different question than the one the sink depends on.

### Proof of concept

Everything below ran against `gitea/gitea:main-nightly`, build `g2e1be0b114` the exact upstream HEAD commit `2e1be0b1144297b1be52ea74cb1927e035bad0f9`, unmodified image, no source patches.

Setup: private repo `admin/secretrepo`, README containing the canary `TOP-SECRET-CANARY-9F3A2` and a sibling file `SECRET.md`. Token minted with `read:user` **only** no `repository` scope at all.

```plaintext
=== anonymous baseline (repo is private) ===
  anon GET /admin/secretrepo                       HTTP=404
=== same no-repo-scope token across routes ===
  API repo get (proves token lacks scope)          HTTP=403 canary=0
  /archive  (patched control)                      HTTP=403 canary=0
  /raw      (patched control)                      HTTP=403 canary=0
  .rss feed (patched control)                      HTTP=403 canary=0
  >>> repo.Home (VULN)                             HTTP=200 canary=1
```

A second token scoped `public-only,read:repository` behaves identically: `/archive` → 403, `/admin/secretrepo` → **200** plus the canary.

Three properties make this PoC worth trusting, and I build every PoC this way:

*   **The controls are in the same run.** The patched siblings returning 403 on the same token, in the same script, prove the token really is under-scoped. Without that row, a reviewer can dismiss the whole thing as "your token had scope."
    
*   **The anonymous baseline is in the same run.** 404 for anon proves the repo is genuinely private and I'm not reading a public repo with extra steps.
    
*   **The canary is a string match, not a status code.** `HTTP=200` proves reachability; `canary=1` proves *content disclosure*. Only the second one is the bug.
    

The whole thing is a ~40-line bash script that spins the container, creates the repo, mints the token, probes five URLs and prints PASS/FAIL. A maintainer can run it in under two minutes on a laptop, and that is the single highest-leverage thing you can put in a report.

### Impact

Any holder of a non-`repository`\-scoped or `public-only` token belonging to a user with private-repo access can read those repositories' **README, root file/directory listing, description, language statistics, license, and latest release**. That's source-adjacent content README files routinely carry internal hostnames, architecture notes and setup instructions, and the file tree alone leaks project structure.

The realistic scenario is the one the feature was built for: you mint a deliberately narrow token for a third-party integration or a CI job, believing it can't touch your code. It can.

Disclosure is bounded to the repository root view the deeper `/{owner}/{repo}/src/*` routes do not enable `AllowBasic`, so this is not full source-tree read. I tested that and said so in the report. **Write and RCE were tested and ruled out; the primitive is read-only, on one view.**

`CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N` = **4.3 Moderate**, matching the severity of the sibling advisories it derives from. I scored it low on purpose. Inflating a Moderate into a High is how you lose a maintainer's attention for the next report.

Submitted through GitHub Security Advisories the day after confirmation. Accepted, GHSA opened, CVE assigned.

## IV. Fix

I sent the report with a concrete patch rather than a suggestion, and offered to open it against the private fork:

```diff
  // routers/web/repo/view_home.go func Home()
+ context.CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)
+ if ctx.Written() {
+     return
+ }

  checkHomeCodeViewable(ctx)
```

plus a regression test using **Basic auth specifically** the route enables `AllowBasic` but not `AllowOAuth2`, so a test written with the usual `token=` query parameter would pass against the vulnerable build and prove nothing. That detail is worth more than the one-line fix.

The report also asked for the systemic half: audit every other `AllowBasic`/`AllowOAuth2` web route for the same omission, and specifically look at `actions.GetWorkflowBadge`.

**What shipped**, in commit `f69e15a` (PR #38406) a batched security release:

| Change | Effect |
| --- | --- |
| `CheckRepoScopedToken(ctx, ctx.Repo.Repository, auth_model.Read)` at the top of `repo.Home`, guarded by `ctx.Written()` | Closes the sink exactly the proposed patch, at the proposed line |
| New `tests/integration/repo_home_token_scope_test.go` `TestRepoHomeContentTokenScopes` | Regression coverage: misc-scope token → 403, public-only token on private repo → 403, `read:repository` → 200, all over Basic auth |
| `routers/web/goget.go` hardened in the same commit | The neighbouring `go get` default-branch leak same route family, same release |

The maintainers wrote their own version of the test rather than taking mine, which is the normal and correct outcome; what mattered was that the report made the Basic-auth requirement explicit so their test tested the real vector.

**What did not ship:** `actions.GetWorkflowBadge` still has no scope guard on `main`. The guard call-site count on the web router went from four to five, not to "all of them." The class is one route smaller. It is not closed.

Merged and released as **1.27.0** on 2026-07-12; advisory published 2026-07-13 as part of a batch of ~45 CVEs.

## V. Timeline

| Date | Event |
| --- | --- |
| 2026-06-25 | Target selected on the incomplete-fix hypothesis from GHSA-cr4g / GHSA-3pww. Route table extracted, `A \ B` set difference run, `repo.Home` identified. |
| 2026-06-25 | End-to-end confirmation on `gitea/gitea:main-nightly` (`g2e1be0b114`). Scope gap proved with patched siblings + anonymous baseline as in-run controls. |
| 2026-06-26 | Reported via GitHub Security Advisories with PoC script and proposed patch. Accepted; private fork opened; fix branch + regression test pushed. |
| 2026-07-12 | Maintainer fix merged to `main` as `f69e15a` (PR #38406) guard + upstream regression test. |
| 2026-07-12 | Released in Gitea **1.27.0**. |
| 2026-07-13 | Advisory published: **GHSA-cp3q-vrj2-ghhh**, Moderate |
| — | **CVE-2026-58444** assigned after GitHub's CVE-rules compliance review. |

## What generalizes

Strip out Gitea and the method is four moves:

1.  **Read the advisories as a corpus, not as individual bugs.** One advisory tells you a bug existed. Five advisories with the same shape tell you the *fix strategy* is wrong. The second signal is worth vastly more.
    
2.  **Turn "is this safe?" into a set difference.** `{routes that admit the dangerous capability} \ {routes that check it}` is a question an agent can answer exhaustively and cite. "Audit this for vulnerabilities" is a question it can only answer creatively. Prefer questions with a fixed row count.
    
3.  **Make the agent argue against you before you touch Docker.** The rebuttal pass either kills the finding for free, or hands you the exact sentence the maintainer will otherwise write in the triage reply.
    
4.  **A finding is a hypothesis until it executes.** Unmodified upstream image, pinned to a commit hash, with the patched siblings as in-run controls and a canary string as the success condition. Everything short of that is a guess with good grammar.
    

The AI does none of the interesting work in step 1 and all of the tedious work in step 2. That split is the whole technique.
