[Security 7/9] Download-token expiry (with legacy back-compat)

⛔ closed · #250 · open-legal-products/mike ← amal66/mike · opened 3mo ago by amal66 · closed 3mo ago · +215-60 across 3 files · ↗ on GitHub

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/:token verification (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 with exp, 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.

⬇ Download capture-pull-250.md