Repository navigation
Description
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
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
Flegma commentedon Jul 10, 2026
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/
matchclips 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.Flegma commentedon Jul 10, 2026
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
demosingress routes/clips(prefix) to the api service onDEMOS_DOMAIN, sohttps://<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 thematch_clipsvisibility 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/
matchclips through an authenticated api route on the app domain instead of the public demos domain. Same applies to thesnapshotingress serving path if it exposes non-public snapshot kinds.bug: the worker requires signed clip links only once the panel's api …
lukepolo commentedon Oct 15, 2026
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.shis re-run against a panel that signs links.bug: re-running backblaze-proxy.sh never turns signed clip links off …
bug: the worker can require a signed, unexpired link for a clip or li…
lukepolo commentedon Oct 15, 2026
Fixed by 5stackgg/api#596, 5stackgg/web#821 and #644. Worker enforcement is on for cf.5stack.gg, verified live:
exp, or a signature taken from another file are refused (403,Cache-Control: no-store)./maps/%2e%2e/clips/…, the double-encoded form,/clips?file=…) is refused (403).maps/latest.jsonstill load unsigned.Re-run
./backblaze-proxy.shafter a panel update, a rollback or anS3_SECRETchange.