Skip to content

Sign file URLs per file instead of putting the file token in them (GRYT-1549) - #251

Merged
sivert-io merged 1 commit into
mainfrom
claude/GRYT-1549-signed-file-urls
Sep 29, 2026
Merged

sivert-io merged 1 commit into
mainfrom
claude/GRYT-1549-signed-file-urls

Conversation

@sivert-io

@sivert-io sivert-io commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

GRYT-1549. File URLs carried the file token in ?t=, because they end up in <img src> and that can't send a header. The desktop and the phone both did it. Those loads can't wait for the identity proof the way fetch does since client#708 and mobile#268, and a token in a URL ends up in history, referrers, logs and screenshots.

What t was

  • A JWT signed with JWT_SECRET, with scope: "file", minted next to the access token in join.ts, joinHelpers.ts (both token:refresh branches) and sessions.ts. So it's file-scoped in the sense that verifyAccessToken refuses it, but it isn't scoped to any one file.
  • It lives 12 hours (FILE_TOKEN_EXPIRY), against the access token's 15 minutes, and is re-minted on every refresh.
  • Its payload is readable by anyone holding it: grytUserId, serverUserId, nickname, serverHost and both token versions.
  • It's checked in fileReader in src/routes/uploads.ts: signature, scope, serverHost against the Host header, the server-wide token_version, and the member's token_version. fileReadVerdict then decides whether that member may read that file.
  • A leaked t reads every upload that member can read on that server, including DM and private-channel attachments, for up to 12 hours, until somebody bumps a token version. It can't do anything else.

Call sites: getUploadsFileUrl in the desktop client (about 25 callers, all synchronous, most inside render), and attachmentUrl on the phone (about 10).

Options

  • Short-lived signed URLs, one per file. Nothing that works as a bearer token goes in a URL, and images load straight from the server with no extra step. The cost is that a URL goes stale, so anything that holds one for long has to sign it again.
  • Fetch with an Authorization header, then a blob URL. This goes through the proof gate for free. But every picture is held twice in memory, the HTTP cache is mostly lost, and video and audio can't stream, since a blob has no range requests. A long video would have to download completely before it plays.
  • A cookie on the server's origin. The web client talks to many servers from one origin, so it would be a third-party cookie (SameSite=None; Secure). Browsers block those more and more, and it doesn't work over plain http, which LAN servers use.

I picked the first one, with a change to how the URLs get minted. The task said the server mints each URL over the socket. That means a round trip before any image can load, and every caller turns async. About 35 call sites build these URLs during render. So the server hands out a signing key instead, and the client signs each URL itself with no round trip. A leaked URL is still good for one file and a few minutes. The key never goes in a URL.

How it works

  • src/utils/fileUrl.ts. The key is HMAC(root, host | serverUserId | tokenVersion | userTokenVersion | until), where root comes from JWT_SECRET with its own label. Nothing is stored. The server derives the key again from the URL.
  • fileKey goes out on server:joined and on both token:refreshed paths. A restored session (session:restore) gets no token:refreshed, so it gets file:key on its own, before the channel list goes out.
  • A URL is ?u=<serverUserId>&k=<until>&e=<expires>&s=<sig>, where sig is HMAC(key, "file-url\n<fileId>\n<thumb|full>\n<expires>"). The route refuses it when it has expired, when expires is more than 10 minutes (plus a minute of clock slack) ahead, or when until has passed. Either token version moving also kills it, because the key changes.
  • Clients sign in five-minute steps, two steps out. So a URL stays the same within a step, which keeps pictures from reloading on every render, and lives 5 to 10 minutes.
  • Compatibility. fileToken is still minted and ?t= is still accepted, so older clients keep their pictures. GRYT-1586 removes both after a release.

Checked

  • yarn test (1818), yarn build, eslint and the comment check pass.
  • fileRead.test.ts goes through the real route. A signed URL reads its file. The same signature on file B gets 401. Removing thumb=1 from a thumbnail link gets 401. Expired gets 401, and so does an expiry an hour out. Ending the member's sessions kills the link, and so does swapping u for somebody else's id. A signer outside a DM still gets the same 404 as a missing file.
  • Mutation-checked: without the file id, the thumb flag, the expiry check, the max-lifetime check or the user in the key, the matching test fails.
  • fileUrl.test.ts holds a fixed vector. The desktop and phone tests check the same one, so the three can't drift apart silently.
  • voiceRecovery.test.ts restores a session over a real socket, gets file:key, and a URL signed with it passes for this host and fails for another.
  • By hand: this branch on :5003, the desktop branch on :3666, headless Chrome. I joined, uploaded an avatar and reloaded, so the session was restored. The avatar and its thumbnail loaded from signed URLs. No request carried t=, none got a 401, and no fileToken_ was left in storage.

What to look at

  • Security-sensitive, but none of it is in a review-required path. src/routes/uploads.ts, src/utils/fileUrl.ts and src/socket/** aren't on the list. I'd still read checkSignedFileUrl and signedFileReader closely.
  • signedFileReader trusts u only as far as the key derived for it. A wrong u gives a different key and fails. fileReadVerdict still runs for that member afterwards.
  • Cache-Control stays private, max-age=60. The URL is still a credential, for one file for ten minutes.
  • Behaviour change: file:key is new on session:restore. Older clients ignore it.

Release order: this one first. The desktop and phone PRs work against an older server too. They fall back to ?t=.

Desktop: Gryt-chat/client#724. Phone: Gryt-chat/mobile#281. Docs: Gryt-chat/docs#152. Follow-up: GRYT-1586 drops ?t= and fileToken.

🤖 Generated with Claude Code

…YT-1549)

The server hands out a signing key with server:joined and token:refreshed,
and on its own as file:key after a restored session. A client signs each
/api/uploads/files/:id URL with it, for one file, thumbnail or full size,
for ten minutes at most. The route still takes ?t= for one release, until
GRYT-1586 drops it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant