[Bug]: Signed asset links for PDFs are scoped to the parent directory, exposing every sibling PDF
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 23k
- Forks
- 5.9k
- Avg merge
- 11h 14m
- Merged PRs (30d)
- 357
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
- Put two PDFs in the same workspace folder, e.g.
docs/a.pdfanddocs/b.pdf. - Have a thread reference
docs/a.pdf, so the server mints an asset link of the form/api/assets/<token>/a.pdf. - Take that link and replace only the trailing filename with
b.pdf. b.pdfis served with200, although it was never referenced in the thread.
Expected behavior
A signed asset link grants access to the file it was minted for, and to nothing the user did not reference.
Actual behavior
Token minting only produces workspace-file-exact claims when isWorkspaceImagePreviewPath(relativePath) is true, i.e. for images. Everything else — including .pdf, .htm and .html — gets workspace-file claims scoped to path.dirname(relativePath).
On serving, the request path is joined onto baseRelativePath and gated only by PREVIEW_ASSET_EXTENSIONS plus a . / .. / dotfile segment check. Every sibling or nested file with a preview extension is therefore reachable, unauthenticated, for the token's lifetime (~1h in my case), including over the public relay host.
A directory scope is the right call for HTML previews, which need to resolve sibling CSS, JS and fonts. A PDF has no sibling resources to resolve, yet it inherits that scope by sharing the browser-preview category with HTML.
To be clear about what is not affected: this is not a path traversal. ../ and %2e%2e%2f are correctly rejected with 404, and files whose extension is outside the allowlist are not served.
Practical consequence: sharing a single asset link — the natural way to hand a document to someone from a phone — silently shares every PDF sitting next to it. Folders that hold one sensitive document among its siblings (signed bank instructions, invoices, contracts) are exactly the case where a user would share one link.
A possible fix would be to mint workspace-file-exact for resources that never need sibling resolution, i.e. keep the directory-scoped claims for .htm / .html and treat .pdf like images.
Impact
Major degradation or frequent failure
Version or commit
0.0.39-nightly.20260902.1257
Environment
macOS 12.6, Node v24.15.0, server started with serve --host 127.0.0.1 --port 3773, link opened through the T3 Connect relay host.
Logs or stack traces
# token redacted; same token in all three requests, minted for <folder>/file-A.pdf
$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' "$BASE/<token>/file-A.pdf"
200 57138 # referenced file — expected
$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' "$BASE/<token>/file-B.pdf"
200 49388 # never referenced, unrelated document in the same folder
$ curl -s --path-as-is -o /dev/null -w '%{http_code}\n' "$BASE/<token>/../../../some/other/path.md"
404 # traversal correctly rejected
Workaround
Send the file itself rather than the asset link whenever the containing folder holds anything the recipient should not see.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in apps/server by locating isWorkspaceImagePreviewPath, workspace-file-exact/workspace-file claim minting, and PREVIEW_ASSET_EXTENSIONS. Reproduce the curl requests from the issue, then trace token validation and serving; done means a PDF token cannot fetch sibling PDFs while HTML previews retain required directory access, with traversal and extension checks still passing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100