Skip to content

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

Closed

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.

Activity

  1. added
    P1-highStability & reliability
    securitySecurity vulnerability or hardening
    audit-2026-07Findings from the 2026-07 code audit / review pass
    on Jul 10, 2026
  2. Flegma commented on Jul 10, 2026

    @Flegma
    CollaboratorAuthor

    Deferred from the 2026-07 fix pass. The naive fix (run the Hasura lookup as the requesting user) does NOT apply: clip URLs are served on the sessionless public demos domain, so there is no user to key row permissions on. The real fix is short-lived signed/tokenized clip URLs, or serving private/match clips through an authenticated api route. That is a design change larger than a fix. See the fixes summary doc (docs/plans/2026-07-10-audit-fixes-summary.md) for the full deferral rationale.

  3. Flegma commented on Jul 10, 2026

    @Flegma
    CollaboratorAuthor

    Unlike the other auth findings (#531/#536/#537/#540), this one is exposed through an ingress and is not just an in-cluster concern. The demos ingress routes /clips (prefix) to the api service on DEMOS_DOMAIN, so https://<demos-domain>/clips/<uuid> is reachable by anyone on the internet. That domain carries no app session, and the controller resolves the clip via an admin-secret Hasura query that bypasses the match_clips visibility permissions, so a private/match-visibility clip's video is downloadable by anyone who has the (or ever saw the) UUID, with no login.

    So this should stay open and is the real auth item from this batch. The fix still needs to be a design change rather than 'run the lookup as the user' (there is no user on the demos domain): short-lived signed/tokenized clip URLs minted by the api after it checks visibility for the requesting session, or serving private/match clips through an authenticated api route on the app domain instead of the public demos domain. Same applies to the snapshot ingress serving path if it exposes non-public snapshot kinds.

  4. added theissue type on Oct 14, 2026
  5. moved this from Backlog to In progress in 5stack - Roadmapon Oct 14, 2026
  6. moved this from In progress to Done in 5stack - Roadmapon Oct 15, 2026
  7. lukepolo commented on Oct 15, 2026

    @lukepolo
    Contributor

    Reopening until the worker half ships. api#596 and web#821 sign clip, thumbnail and lineup links and enforce them on the api's demos-domain route. Panels serving clips through the Cloudflare worker still serve them unsigned until #644 is merged and ./backblaze-proxy.sh is re-run against a panel that signs links.

  8. lukepolo commented on Oct 15, 2026

    @lukepolo
    Contributor

    Fixed by 5stackgg/api#596, 5stackgg/web#821 and #644. Worker enforcement is on for cf.5stack.gg, verified live:

    • A signed clip, thumbnail and lineup preview are served (200).
    • The same files with no signature, a forged signature, an expired exp, or a signature taken from another file are refused (403, Cache-Control: no-store).
    • Path smuggling (/maps/%2e%2e/clips/…, the double-encoded form, /clips?file=…) is refused (403).
    • Non-clip assets such as maps/latest.json still load unsigned.

    Re-run ./backblaze-proxy.sh after a panel update, a rollback or an S3_SECRET change.

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

    P1-highStability & reliabilityaudit-2026-07Findings from the 2026-07 code audit / review passsecuritySecurity vulnerability or hardeningservice:api5stackgg/api serviceservice:infrastructure5stackgg/5stack-panel infrastructure

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions