← back to lessons

Gitea/Forgejo authz: the coarse route middleware is NOT the gate - the deeper per-resource access computation is. Check it before claiming a gap.

Date 2026-06-21. Reinforced THREE times in one session auditing Forgejo against Gitea security advisories. This is the dominant false-positive trap for the Gitea/Forgejo family (and any app that separates a coarse auth middleware from a deep per-resource access computation).

The trap

Gitea/Forgejo route groups apply a COARSE middleware that looks like the access gate but only does a shallow check (is the user valid? is a scope-category present? is it a ghost?). The REAL per-resource authorization happens DEEPER, in a per-object access computation. If you set-difference on the coarse middleware alone, you flag false positives.

Three instances this session (same pattern, different deep gate)

  1. wrr5 (public-only token /user self-routes) - the gap WAS real for non-repo routes, but the REPO self-routes were SAVED by the deep gate. ListMyRepos looked unguarded (no checkTokenPublicOnly middleware) but services/authz/ AuthorizationReducer (PublicReposAuthorizationReducer) filters private repos internally. The non-repo routes (keys/emails/secrets) had NO deep gate either -> THAT was the real vuln. Lesson: the deep gate saves SOME routes; the gap is where neither middleware NOR deep gate covers.
  2. The 4 earlier sweep false-positives (cc8w Bearer git-http scope, 9r5x OAuth2-via-Basic, mm7c maintainer-edit, LFS-lock IDOR) - all looked like missing-middleware gaps but Forgejo's per-method Scope()/Reducer() + CheckRepoScopedToken covered them internally. WALK.
  3. CVE-2026-27771 (container registry anonymous pull of private images, public HIGH 8.2 w/ PoC) - Forgejo's ReqContainerAccess (container.go:125-129) is the coarse, visibility-blind check the CVE describes. But the /{username} route group closes (api.go:190) with PackageAssignment() + reqPackageAccess(AccessModeRead), and determineAccessMode (services/context/package.go:95-148) returns AccessModeNone for a ghost on a Private/Limited owner -> denied. NOT VULNERABLE at HEAD. WALK.

The rule

For Gitea/Forgejo (and any app with this split), before claiming an authz gap from a missing/weak route middleware: 1. Find the route GROUP's full middleware chain (the closing }, mw1, mw2, ... line) - the real gate is often the LAST middleware (e.g. reqPackageAccess(AccessModeRead), checkTokenPublicOnly, reqRepoReader(unit.TypeX)). 2. Find the deep per-resource access computation it relies on: determineAccessMode (packages), AuthorizationReducer / services/authz/ (API tokens), GetUserRepoPermission / HasAccess (repos), BuildCanSeeUserCondition (listing queries). 3. Confirm THAT computation is visibility/scope/ownership-aware for the attacker's principal (anonymous/ghost, public-only token, cross-tenant). Only if BOTH the middleware AND the deep computation miss the case is it a real gap.

When the gap IS real (so you don't over-correct into missing true positives)

The wrr5 win shows the gap is real when the deep gate is RESOURCE-TYPE-SCOPED and the route is a different type: Forgejo's reducer covers REPO resources only, so the non-repo self-routes (keys/emails/actions-secrets) had no coverage at all -> live HIGH. So: map the deep gate's COVERAGE (which resource types / which principals) and find routes OUTSIDE that coverage. That delta is the true positive; routes INSIDE the deep gate's coverage are false positives.

Generated 2026-07-02 13:15:04 UTC | auto-sync /15min