Repository navigation
Sign file URLs per file instead of putting the file token in them (GRYT-1549) - #251
Merged
Merged
Conversation
…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]>
This was referenced Sep 28, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
twasJWT_SECRET, withscope: "file", minted next to the access token injoin.ts,joinHelpers.ts(bothtoken:refreshbranches) andsessions.ts. So it's file-scoped in the sense thatverifyAccessTokenrefuses it, but it isn't scoped to any one file.FILE_TOKEN_EXPIRY), against the access token's 15 minutes, and is re-minted on every refresh.grytUserId,serverUserId,nickname,serverHostand both token versions.fileReaderinsrc/routes/uploads.ts: signature,scope,serverHostagainst theHostheader, the server-widetoken_version, and the member'stoken_version.fileReadVerdictthen decides whether that member may read that file.treads 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:
getUploadsFileUrlin the desktop client (about 25 callers, all synchronous, most inside render), andattachmentUrlon the phone (about 10).Options
Authorizationheader, 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.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 isHMAC(root, host | serverUserId | tokenVersion | userTokenVersion | until), whererootcomes fromJWT_SECRETwith its own label. Nothing is stored. The server derives the key again from the URL.fileKeygoes out onserver:joinedand on bothtoken:refreshedpaths. A restored session (session:restore) gets notoken:refreshed, so it getsfile:keyon its own, before the channel list goes out.?u=<serverUserId>&k=<until>&e=<expires>&s=<sig>, wheresigisHMAC(key, "file-url\n<fileId>\n<thumb|full>\n<expires>"). The route refuses it when it has expired, whenexpiresis more than 10 minutes (plus a minute of clock slack) ahead, or whenuntilhas passed. Either token version moving also kills it, because the key changes.fileTokenis 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.tsgoes through the real route. A signed URL reads its file. The same signature on file B gets 401. Removingthumb=1from 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 swappingufor somebody else's id. A signer outside a DM still gets the same 404 as a missing file.fileUrl.test.tsholds a fixed vector. The desktop and phone tests check the same one, so the three can't drift apart silently.voiceRecovery.test.tsrestores a session over a real socket, getsfile:key, and a URL signed with it passes for this host and fails for another.t=, none got a 401, and nofileToken_was left in storage.What to look at
src/routes/uploads.ts,src/utils/fileUrl.tsandsrc/socket/**aren't on the list. I'd still readcheckSignedFileUrlandsignedFileReaderclosely.signedFileReadertrustsuonly as far as the key derived for it. A wrongugives a different key and fails.fileReadVerdictstill runs for that member afterwards.Cache-Controlstaysprivate, max-age=60. The URL is still a credential, for one file for ten minutes.file:keyis new onsession: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=andfileToken.🤖 Generated with Claude Code