From 37d724375e0edd436ab3b7b9a53c50d3a336f087 Mon Sep 17 00:00:00 2001 From: Amal Date: Fri, 17 Jul 2026 00:14:43 -0700 Subject: [PATCH] security: reject download tokens without an expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Download tokens were HMAC-signed but never expired: any token ever issued (e.g. one lingering in old chat history or a shared link) remained valid forever. Ported from the fork's hardening: token payloads now carry an expiry (30-day default TTL), verification rejects expired tokens, and — fail-closed — tokens without an expiry field are rejected rather than treated as eternal. Mechanical port of apps/api/src/core/downloadTokens.ts and apps/api/src/lib/downloadTokens.ts from amal66/mike@b3166dd into the backend/ layout, plus the fork's token-expiry test suite. tsconfig excludes test files from the build (same exclusion the test-harness PR adds) so `npm run build` stays green before vitest lands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC --- backend/src/core/downloadTokens.ts | 97 +++++++++++++++++++ .../__tests__/downloadTokens.expiry.test.ts | 37 +++++++ backend/src/lib/downloadTokens.ts | 71 +++----------- backend/tsconfig.json | 2 +- 4 files changed, 151 insertions(+), 56 deletions(-) create mode 100644 backend/src/core/downloadTokens.ts create mode 100644 backend/src/lib/__tests__/downloadTokens.expiry.test.ts diff --git a/backend/src/core/downloadTokens.ts b/backend/src/core/downloadTokens.ts new file mode 100644 index 000000000..03b55e809 --- /dev/null +++ b/backend/src/core/downloadTokens.ts @@ -0,0 +1,97 @@ +import crypto from "crypto"; + +export type DownloadTokenPayload = { + path: string; + filename: string; + /** Unix timestamp (seconds) after which the token is invalid. */ + exp?: number; +}; + +function b64urlEncode(buf: Buffer): string { + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function b64urlDecode(s: string): Buffer { + let t = s.replace(/-/g, "+").replace(/_/g, "/"); + while (t.length % 4) t += "="; + return Buffer.from(t, "base64"); +} + +function timingSafeEqStr(a: string, b: string): boolean { + const maxLen = Math.max(a.length, b.length, 1); + const aBuf = Buffer.alloc(maxLen, 0); + const bBuf = Buffer.alloc(maxLen, 0); + Buffer.from(a).copy(aBuf); + Buffer.from(b).copy(bBuf); + return crypto.timingSafeEqual(aBuf, bBuf) && a.length === b.length; +} + +const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days + +export function signDownloadPayload( + payload: DownloadTokenPayload, + secret: string, + ttlSeconds = DEFAULT_TTL_SECONDS, +): string { + const exp = payload.exp ?? Math.floor(Date.now() / 1000) + ttlSeconds; + const encodedPayload = b64urlEncode( + Buffer.from( + JSON.stringify({ p: payload.path, f: payload.filename, e: exp }), + "utf8", + ), + ); + const signature = crypto + .createHmac("sha256", secret) + .update(encodedPayload) + .digest(); + return `${encodedPayload}.${b64urlEncode(signature)}`; +} + +export function verifyDownloadPayload( + token: string, + secret: string, +): DownloadTokenPayload | null { + const parts = token.split("."); + if (parts.length !== 2) return null; + + const [encodedPayload, encodedSignature] = parts; + const expectedSignature = crypto + .createHmac("sha256", secret) + .update(encodedPayload) + .digest(); + + if (!timingSafeEqStr(encodedSignature, b64urlEncode(expectedSignature))) { + return null; + } + + try { + const parsed = JSON.parse( + b64urlDecode(encodedPayload).toString("utf8"), + ) as { + p: unknown; + f: unknown; + e?: unknown; + }; + if (typeof parsed.p !== "string" || typeof parsed.f !== "string") { + return null; + } + if (!parsed.p || !parsed.f) return null; + // Every token must carry an expiry. A token without `e` is legacy (issued + // before expiry existed) and would otherwise be valid forever, so reject it + // — any such link is long stale (all issuers have set `e` since), and a + // fresh, expiring token is re-issued on next access. + if ( + typeof parsed.e !== "number" || + parsed.e < Math.floor(Date.now() / 1000) + ) { + return null; + } + return { path: parsed.p, filename: parsed.f }; + } catch { + return null; + } +} diff --git a/backend/src/lib/__tests__/downloadTokens.expiry.test.ts b/backend/src/lib/__tests__/downloadTokens.expiry.test.ts new file mode 100644 index 000000000..5bc7393ca --- /dev/null +++ b/backend/src/lib/__tests__/downloadTokens.expiry.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { signDownloadPayload, verifyDownloadPayload } from "../../core/downloadTokens"; + +describe("token expiry", () => { + const SECRET = "expiry-test-secret-value-32bytes!"; + + it("accepts a token whose exp is in the future", () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600; + const token = signDownloadPayload( + { path: "p", filename: "f.pdf", exp: futureExp }, + SECRET, + ); + expect(verifyDownloadPayload(token, SECRET)).not.toBeNull(); + }); + + it("rejects a token whose exp is in the past", () => { + const pastExp = Math.floor(Date.now() / 1000) - 1; + const token = signDownloadPayload( + { path: "p", filename: "f.pdf", exp: pastExp }, + SECRET, + ); + expect(verifyDownloadPayload(token, SECRET)).toBeNull(); + }); + + it("rejects a legacy token with no exp field (would otherwise never expire)", () => { + // Manually build a well-signed token without the 'e' field to simulate + // old tokens. Such a token was previously accepted forever; it must now + // be rejected so every valid token carries an expiry. + const b64u = (buf: Buffer) => + buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + const crypto = require("crypto") as typeof import("crypto"); + const payload = b64u(Buffer.from(JSON.stringify({ p: "path", f: "file.pdf" }))); + const sig = b64u(crypto.createHmac("sha256", SECRET).update(payload).digest()); + const token = `${payload}.${sig}`; + expect(verifyDownloadPayload(token, SECRET)).toBeNull(); + }); +}); diff --git a/backend/src/lib/downloadTokens.ts b/backend/src/lib/downloadTokens.ts index 71207fc5a..4ea5fa6f3 100644 --- a/backend/src/lib/downloadTokens.ts +++ b/backend/src/lib/downloadTokens.ts @@ -1,4 +1,7 @@ -import crypto from "crypto"; +import { + signDownloadPayload, + verifyDownloadPayload, +} from "../core/downloadTokens"; /** * HMAC-signed, non-expiring download tokens. @@ -10,66 +13,24 @@ import crypto from "crypto"; */ function getSecret(): string { - const secret = process.env.DOWNLOAD_SIGNING_SECRET; - if (!secret) { - throw new Error( - "DOWNLOAD_SIGNING_SECRET must be set. " + - "Generate a strong random value (e.g. `openssl rand -hex 32`) and set it in the environment.", - ); - } - return secret; -} - -function b64urlEncode(buf: Buffer): string { - return buf - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); -} - -function b64urlDecode(s: string): Buffer { - let t = s.replace(/-/g, "+").replace(/_/g, "/"); - while (t.length % 4) t += "="; - return Buffer.from(t, "base64"); -} - -function timingSafeEqStr(a: string, b: string): boolean { - if (a.length !== b.length) return false; - return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); + const secret = process.env.DOWNLOAD_SIGNING_SECRET; + if (!secret) { + throw new Error( + "DOWNLOAD_SIGNING_SECRET must be set. " + + "Generate a strong random value (e.g. `openssl rand -hex 32`) and set it in the environment.", + ); + } + return secret; } export function signDownload(path: string, filename: string): string { - const payload = JSON.stringify({ p: path, f: filename }); - const enc = b64urlEncode(Buffer.from(payload, "utf8")); - const sig = crypto - .createHmac("sha256", getSecret()) - .update(enc) - .digest(); - return `${enc}.${b64urlEncode(sig)}`; + return signDownloadPayload({ path, filename }, getSecret()); } export function verifyDownload( - token: string, + token: string, ): { path: string; filename: string } | null { - const parts = token.split("."); - if (parts.length !== 2) return null; - const [enc, sigEnc] = parts; - const expected = crypto - .createHmac("sha256", getSecret()) - .update(enc) - .digest(); - if (!timingSafeEqStr(sigEnc, b64urlEncode(expected))) return null; - try { - const parsed = JSON.parse(b64urlDecode(enc).toString("utf8")) as { - p: string; - f: string; - }; - if (!parsed?.p || !parsed?.f) return null; - return { path: parsed.p, filename: parsed.f }; - } catch { - return null; - } + return verifyDownloadPayload(token, getSecret()); } /** @@ -77,5 +38,5 @@ export function verifyDownload( * prefixes it with NEXT_PUBLIC_API_BASE_URL when rendering ``. */ export function buildDownloadUrl(path: string, filename: string): string { - return `/download/${signDownload(path, filename)}`; + return `/download/${signDownload(path, filename)}`; } diff --git a/backend/tsconfig.json b/backend/tsconfig.json index a4b3abf67..bc27281c0 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -16,5 +16,5 @@ } }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"] }