Skip to main content

Command Palette

Search for a command to run...

One Route The Patch Forgot

Updated
13 min readView as Markdown
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.

It is one of the most popular self-hosted developer-infrastructure projects on GitHub (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

  • 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

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:

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.

=== 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/secretrepo200 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:

  // 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.