Skip to content

[API] Clip download/thumbnail endpoints bypass the match_clips visibility model (IDOR) #544

Description

@Flegma

Location: src/matches/clips/clip-download.controller.ts:30 (api)

What: ClipDownloadController.download (line 22) and thumbnail (line 45) have no auth guard and resolve the clip via getClip (line 66), which calls this.hasura.query({match_clips_by_pk}) with no steamId, i.e. with the admin secret (hasura.service.ts line 78-88). This bypasses the match_clips select permissions (public_match_clips.yaml lines 60-127) that restrict private clips to their owner and 'match'-visibility clips to the match organizer/players. The file key is then streamed directly from S3. The URLs are served on the public demosDomain (clip_download_url.sql line 57: CONCAT(demos_domain,'/clips/',id,...)). SnapshotController.getSnapshot (snapshot.controller.ts line 43) has the same gap: kinds 'clips'/'demo'/'bake' are served with no authorization at all (only 'live' is gated).

Impact: A user creates a clip with visibility='private' (or 'match', shared only with the opponents in a match). Anyone who ever obtained the clip UUID (a match participant, or someone who saw it while it was public before it was set private) can GET https:///clips/ with no session and download the full video, and GET /clips//thumbnail, defeating the entire clip privacy model. No authentication is required.

Suggested fix: In getClip, run the Hasura lookup as the requesting user (pass request.user.steam_id so row permissions apply) or explicitly re-check clip.visibility plus match membership before streaming, and require an authenticated session for non-public clips; apply the same check in SnapshotController for the clips/demo kinds.

Verifier evidence

clip-download.controller.ts:14 @Controller("/clips/:clipId") (no guard); :30 const clip = await this.getClip(clipId);; :70-76 const { match_clips_by_pk } = await this.hasura.query({ match_clips_by_pk: { __args: { id: clipId }, file: true, thumbnail_url: true } }); (no steamId arg).
hasura.service.ts:78-88 getClient(steamId?), headers always include "x-hasura-admin-secret": this.config.secret and add user headers only ...(steamId ? await this.getHasuraHeaders(steamId) : {}), so a missing steamId => admin role, no row filter.
public_match_clips.yaml:80-82 guest filter visibility: _neq: private; :104-126 user filter _or [user_steam_id _eq X-Hasura-User-Id | visibility _eq public | (visibility _eq match AND match organizer/lineup player)].
clip_download_url.sql:57 RETURN CONCAT(demos_domain, '/clips/', match_clips.id, '?name=', download_name, '&v=', version); served on public demos domain.
matches.module.ts:392-414 configure() forRoutes list does not include /clips/:clipId.
snapshot.controller.ts:36-41 requireLogin gate applies only when kind === "live"; clips/demo/bake served unguarded.


Found by the 2026-07 multi-agent code audit; adversarially verified (CONFIRMED, P1). One of a batch of findings from that pass.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1-highStability & reliabilityaudit-2026-07Findings from the 2026-07 code audit / review passsecuritySecurity vulnerability or hardeningservice:api5stackgg/api service

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions