Date: 2026-06-13 | Target: Gitea (self-hosted in Docker, localhost) | Result: CONFIRMED Moderate (feed token-scope bypass) | Lane: Web2 active self-host (newly unlocked, first run)
Self-host + white-box + live PoC is a high-yield loop. Stand up the OSS target in Docker (localhost-only = ROE-clean), clone its source, audit at HEAD, then empirically confirm against the running instance. Gitea fit a 1.9GB box easily (SQLite, ~150MB idle); GitLab/Discourse/Mattermost+Postgres do not - right-size to the box (box stability > target prestige; a crashed box loses the session).
Rule-38.4 generalizes to "audit the whole enforcement CLASS, not the patched endpoint." A cluster of fresh advisories (Gitea Jun-5-2026: archive, /user/orgs, OAuth2-basic, Git-Smart-HTTP-bearer, issue-template) all keyed on ONE mechanism: token-scope enforcement is applied per-handler (checkDownloadTokenScope / checkTokenPublicOnly called inline route-by-route). Per-handler enforcement = the maintainers WILL miss an endpoint. The winning move: enumerate every route that (a) opts into token auth and (b) serves the protected resource, then diff against the set that actually calls the check. The gap is the finding. Here: raw/media/archive call it; the RSS/Atom feed handlers (same AllowBasic, same private content) never did -> bypass.
The auth-flag table is the cheap discriminator. In Gitea, PAT/OAuth2 auth only runs where a route carries webAuth.AllowBasic/AllowOAuth2 (web.go:127-132 adds the methods conditionally). So: grep AllowBasic|AllowOAuth2 -> map to handler -> check each for the scope gate. Routes WITHOUT the flags accept session-cookie only (no token scope to bypass) and are false positives - prune them before claiming (saved a bad submission on .patch/.diff/blame/wiki-raw, which lack the flags).
Contrast-PoC = airtight proof. Same token, two endpoints: read:issue PAT gets 403 on /raw (scope enforced) and 200+data on /rss/branch (scope bypassed); anonymous=404, bogus-token=401, full-scope=200. The 4-row truth table kills any "it's just broken-open" or "user lacks perm" counter-argument - it isolates the variable to token scope.
.patch/.diff (RawDiff), pull .diff/.patch, RefBlame, WikiRaw, RefCommits: handlers lack the scope check BUT lack AllowBasic/AllowOAuth2 -> session-only -> NOT vulnerable. White-box pruning before the live test avoided a false-positive report.checkTokenPublicOnly switch "first-match" flaw (advisory Issue 2): already fixed at HEAD (now a for-range loop over all categories with return-on-fail = AND-of-all-checks). Verified fixed, not a finding.When a project enforces authorization/scope inside each handler (not via a single choke-point middleware), and you see >=2 advisories patching "endpoint X was missing the check," the next bug is almost always endpoint X+1 in the same class. Build the table: {routes that accept the privileged auth} x {routes that call the check}. Set-difference = candidate findings. Confirm with a same-token contrast PoC.
Transfers to: any framework with route-group auth flags (Gitea/Gogs forks, Rails before_action per-controller, Express per-route middleware). The smell is "fix added a function call to handler N" in the changelog.
A target can be a goldmine in one authorization dimension and fully covered in another. On Gitea:
- Token-scope dimension (does a PAT have the right scope for this content): GOLDMINE - the feed handlers carry AllowBasic but miss checkDownloadTokenScope = the confirmed finding. Per-handler, incomplete.
- Unit-access dimension (can this user read this repo unit at all): COVERED - every API content group carries a CanRead(unitType) gate (reqRepoReader / mustEnableWiki which is CanRead(TypeWiki) / mustEnableIssues). Set-difference empty; GHSA-3fwp (issue-template) fixed. Systematically hardened.
So: don't conclude "per-handler authz = bug" globally. Enumerate the gate-wiring SEPARATELY for each dimension (auth-method flags, token-scope checks, unit-readers, object-ownership checks). The outlier dimension - the one where the gate is applied inconsistently vs its siblings - is where the bug is. Cheap tell: for each dimension, grep its gate function and diff the route set that has it against the route set that serves the protected resource. Empty diff = covered dimension, move on; non-empty = candidate. This is what separated the feed finding (token-scope, non-empty diff) from the clean unit-access result (empty diff) on the same target.
First fruit of the Web2 active-self-host lane the binding-constraint (docker) just unlocked. Validates the operator's "higher-EV lane" call: a confirmed current-version Moderate in one session, vs months of passive-only walks. INDEX-worthy: new target-class "self-hostable OSS app, per-handler authz" with the set-difference primitive above.