Summary
/drivers/[id] runs exactly one access check — requireRmrMember, which resolves the deployment default league — and then renders that driver's history across every league. So a viewer admitted to the default league can read a driver's results from a league they have no access to, including one whose accessGate is required and whose own routes would redirect them.
Not currently exploitable on this deployment: the only non-default league is rmsolo, which is accessGate: "optional", so the data reachable this way is public anyway. This becomes a live authorization hole the moment a deployment has a second required league — which is a supported configuration, and is exactly what the per-league membership work in PR 3 enabled.
Found while addressing Copilot's review of #176, which flagged the same shape on the new /me card. That instance is fixed; this one predates it and is the same bug one level up.
Mechanism
The gate is default-league-only (apps/web/src/app/drivers/[id]/page.tsx:17):
await requireRmrMember(`/drivers/${id}`);
DriverPageView then widens the scope to all leagues by two independent paths (apps/web/src/app/drivers/[id]/driver-page-view.tsx):
?league=all sets filter.leagueIds = "all" (line 105), reachable by anyone who edits the URL, and surfaced in the UI as a league chip whenever the driver has more than one league.
- Lines 114-116: when no query params are given at all and the driver has no footprint in the default league, the filter silently falls back to
"all". No crafted URL is needed — a bare /drivers/123 is enough.
buildDriverHistory honors that faithfully: resolveScope maps "all" to no league where clause at all, so nothing downstream re-checks access.
/l/[league]/drivers/[id] is not affected. It gates on its own league via requireMember and passes lockedLeagueSlug, which pins the filter and drops any conflicting ?league=/?season= param.
Suggested fix
Resolve the driver's leagues against the session and pass explicit ids rather than "all", the same approach /me now uses — accessibleLeagues() in apps/web/src/app/me/page.tsx filters through checkLeagueAccess, which short-circuits to allow for non-required gates with no session or DB read. The filter bar's league chips should then be built from the accessible set too, so the UI never offers a scope the page will refuse.
Worth deciding at the same time: whether /drivers/[id] should keep gating on the default league at all, or gate on the union of leagues the viewer can reach. As written, a viewer with access to a non-default league but not the default one cannot open the legacy route for their own driver page, which is why /me has to route them to /l/<league>/drivers/<id> instead.
Acceptance
A signed-in viewer who is not admitted to league B sees no league B events, counts, positions, or chips on /drivers/[id], via ?league=all, via the no-params fallback, or via any ?season= naming a league B season. Covered by a test with two leagues in one DB where the second is accessGate: "required" and the session holds no membership for it — apps/web/tests/multi-league.test.ts already has the two-league fixture shape to build on.
Summary
/drivers/[id]runs exactly one access check —requireRmrMember, which resolves the deployment default league — and then renders that driver's history across every league. So a viewer admitted to the default league can read a driver's results from a league they have no access to, including one whoseaccessGateisrequiredand whose own routes would redirect them.Not currently exploitable on this deployment: the only non-default league is
rmsolo, which isaccessGate: "optional", so the data reachable this way is public anyway. This becomes a live authorization hole the moment a deployment has a secondrequiredleague — which is a supported configuration, and is exactly what the per-league membership work in PR 3 enabled.Found while addressing Copilot's review of #176, which flagged the same shape on the new
/mecard. That instance is fixed; this one predates it and is the same bug one level up.Mechanism
The gate is default-league-only (
apps/web/src/app/drivers/[id]/page.tsx:17):DriverPageViewthen widens the scope to all leagues by two independent paths (apps/web/src/app/drivers/[id]/driver-page-view.tsx):?league=allsetsfilter.leagueIds = "all"(line 105), reachable by anyone who edits the URL, and surfaced in the UI as a league chip whenever the driver has more than one league."all". No crafted URL is needed — a bare/drivers/123is enough.buildDriverHistoryhonors that faithfully:resolveScopemaps"all"to no leaguewhereclause at all, so nothing downstream re-checks access./l/[league]/drivers/[id]is not affected. It gates on its own league viarequireMemberand passeslockedLeagueSlug, which pins the filter and drops any conflicting?league=/?season=param.Suggested fix
Resolve the driver's leagues against the session and pass explicit ids rather than
"all", the same approach/menow uses —accessibleLeagues()inapps/web/src/app/me/page.tsxfilters throughcheckLeagueAccess, which short-circuits to allow for non-required gates with no session or DB read. The filter bar's league chips should then be built from the accessible set too, so the UI never offers a scope the page will refuse.Worth deciding at the same time: whether
/drivers/[id]should keep gating on the default league at all, or gate on the union of leagues the viewer can reach. As written, a viewer with access to a non-default league but not the default one cannot open the legacy route for their own driver page, which is why/mehas to route them to/l/<league>/drivers/<id>instead.Acceptance
A signed-in viewer who is not admitted to league B sees no league B events, counts, positions, or chips on
/drivers/[id], via?league=all, via the no-params fallback, or via any?season=naming a league B season. Covered by a test with two leagues in one DB where the second isaccessGate: "required"and the session holds no membership for it —apps/web/tests/multi-league.test.tsalready has the two-league fixture shape to build on.