Skip to content

bug: /drivers/[id] renders every league's data but gates only on the default league #177

Description

@alchemydc

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions