[Security 7/9] Download-token expiry (with legacy back-compat)
From the PR description
[Security 7/9] Download-token expiry (with legacy back-compat)
Part of the split of #227 into single-topic PRs. Index: tracking comment on #227.
TL;DR
Download links are HMAC-signed tokens encoding an R2 path + filename. This PR adds a mandatory exp claim to every newly issued token (30-day default TTL) and rejects expired or malformed-exp tokens. Well-signed legacy tokens without an exp claim are accepted transitionally so historical links baked into chat history don't 404 on deploy.
Risk to user data
Severity: medium-high. A non-expiring download token is a permanent capability. In a legal product it points at a confidential document, and it leaks the way URLs always leak - chat logs, browser history, forwarded emails, referrer headers. A leaked eternal link is a confidential document exposed forever, to anyone who ever sees the URL. Time-boxing bounds that exposure window.
Flows affected
- Download link issuance (
signDownload/signDownloadPayload). /download/:tokenverification (verifyDownload/verifyDownloadPayload).- Links persisted in historical chat messages (the back-compat concern).
Attack precedent
The general principle - credentials must be time-boxed - is codified for signed tokens in RFC 8725 (JWT BCP): always set and validate exp. Long-lived bearer URLs are a recurring source of data exposure (leaked pre-signed S3 URLs, permalink leaks via referrer).
Possible fixes, and what we chose
| Option | Verdict |
|---|---|
| Keep non-expiring tokens | The status quo. Eternal capability. Rejected. |
Add exp and reject any token without one |
Correct end-state, but upstream's entire issuance history signed {p, f} with no exp, and those permalinks live in persisted chat messages - this would 404 every historical link the instant it deploys (a silent flag-day). |
| Switch to short-lived pre-signed R2 URLs | Larger change; loses the "safe to store in chat history" property this token scheme was built for. Out of scope here. |
New tokens carry exp and are enforced; legacy no-exp tokens accepted transitionally (still HMAC-verified) |
Chosen. Bounds all new exposure immediately while keeping existing links working. Legacy tokens are still signature-verified, so only the server could have minted them. |
flowchart TD
T["/download/:token"] --> S{HMAC signature valid?}
S -- no --> Rej["reject (404)"]
S -- yes --> E{has exp claim?}
E -- "yes" --> V{exp well-formed<br/>and in the future?}
V -- yes --> OK["serve file"]
V -- "no / expired / malformed" --> Rej
E -- "no (legacy)" --> OK2["serve file<br/>(transitional back-compat)"]
Disclosed trade-off: accepting legacy exp-less tokens is a deliberate, temporary compromise, called out in-code. The intended end state is strict exp enforcement once historical links have aged out or been re-issued on access - a one-line tightening. downloadTokens.expiry.test.ts locks in all four behaviours: fresh accepted (exp present), legacy accepted, expired rejected, tampered/malformed-exp rejected (including stripping exp as a tamper route). This PR reflects the reviewed final policy (the strict-then-relaxed history from #227 is collapsed into the transitional policy plus its tests).
What's in this PR
backend/src/core/downloadTokens.ts- new signer/verifier withexp, timing-safe compare, legacy branch.backend/src/lib/downloadTokens.ts- delegates to core; doc header reflects the policy.- Tests:
downloadTokens.expiry.test.ts(7 tests).
Reading
RFC 8725 (JWT Best Current Practices) · OWASP Access Control Cheat Sheet
Our analysis
Add expiry to download tokens — read the full analysis →
Think the analysis missed something the PR description covers?
Capture this PR into my fork
Download a Markdown prompt that tells Claude how to port every
commit in this PR into your working tree. Run it via
claude -p < capture-pull-250.md from
inside the repo you want the changes in.