From 7124a82298ba8696b35da471c054ea65583f09e6 Mon Sep 17 00:00:00 2001 From: Fabian Fischer Date: Fri, 5 Jun 2026 15:22:30 +0200 Subject: [PATCH] Authenticate file downloads with their capability token alone (#1351) The daemon's bearer middleware gated every route except /api/health behind the daemon password (PASEO_PASSWORD), including /api/files/download. That route already carries a single-use, 60s-TTL, crypto-random download token that is only ever issued over the authenticated WebSocket, so the token is the capability for the route. Requiring the daemon password on top of the token broke browser and Electron downloads: those trigger the download via an anchor navigation, which cannot attach an Authorization header. The cross-platform download store (packages/app/src/stores/download-store.ts) therefore sent no bearer, and the daemon returned 401 whenever a password was configured. Add /api/files/download to the bearer-auth bypass list. The endpoint still rejects requests without a valid token (400 missing / 403 invalid), so it stays authenticated; it just no longer demands the password a second time. This also fixes every already-released client without an app update. --- packages/server/src/server/auth.test.ts | 13 +++++++++ packages/server/src/server/auth.ts | 18 +++++++++++- .../server/src/server/bootstrap-auth.test.ts | 29 ++++++++++++++++--- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/auth.test.ts b/packages/server/src/server/auth.test.ts index 6cf208ae4..1bcf05392 100644 --- a/packages/server/src/server/auth.test.ts +++ b/packages/server/src/server/auth.test.ts @@ -7,6 +7,7 @@ import { hashDaemonPassword, isBearerTokenValidAsync, isBearerTokenValid, + shouldBypassBearerAuth, } from "./auth.js"; const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW"; @@ -52,4 +53,16 @@ describe("daemon bearer validator", () => { expect(extractWsBearerToken(protocol)).toBe("secret.with.dots"); expect(extractWsBearerToken("paseo.other.secret")).toBeNull(); }); + + test("bypasses bearer auth for preflight, liveness, and capability-token routes", () => { + // Preflight is always bypassed regardless of path. + expect(shouldBypassBearerAuth("OPTIONS", "/api/status")).toBe(true); + // Unauthenticated liveness probe. + expect(shouldBypassBearerAuth("GET", "/api/health")).toBe(true); + // Guarded by its own single-use download token, not the daemon password. + expect(shouldBypassBearerAuth("GET", "/api/files/download")).toBe(true); + // Everything else stays behind the daemon password. + expect(shouldBypassBearerAuth("GET", "/api/status")).toBe(false); + expect(shouldBypassBearerAuth("POST", "/api/files/upload")).toBe(false); + }); }); diff --git a/packages/server/src/server/auth.ts b/packages/server/src/server/auth.ts index 2247597f9..4e2e7ee7f 100644 --- a/packages/server/src/server/auth.ts +++ b/packages/server/src/server/auth.ts @@ -118,9 +118,25 @@ export function createRequireBearerMiddleware( }; } +// Routes that authenticate via their own capability and therefore must not be +// gated a second time behind the daemon password. +const BEARER_AUTH_BYPASS_PATHS = new Set([ + // Unauthenticated liveness probe. + "/api/health", + // Guarded by a single-use download token (crypto-random UUID, 60s TTL, + // consumed on first use) that is only ever issued over the + // already-authenticated WebSocket. The token IS the capability for this + // route. Requiring the daemon password on top of it breaks browser and + // Electron downloads: those trigger the download via an anchor navigation, + // which cannot attach an `Authorization` header. The download endpoint still + // rejects requests without a valid token (400/403), so dropping the bearer + // here does not make the route unauthenticated. + "/api/files/download", +]); + export function shouldBypassBearerAuth(method: string, path: string): boolean { if (method === "OPTIONS") { return true; } - return path === "/api/health"; + return BEARER_AUTH_BYPASS_PATHS.has(path); } diff --git a/packages/server/src/server/bootstrap-auth.test.ts b/packages/server/src/server/bootstrap-auth.test.ts index a8d98ef4a..2fa7c860a 100644 --- a/packages/server/src/server/bootstrap-auth.test.ts +++ b/packages/server/src/server/bootstrap-auth.test.ts @@ -65,18 +65,39 @@ describe("daemon bearer auth", () => { auth: { password: CORRECT_PASSWORD_HASH }, }); try { - const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`); + const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`); expect(missing.status).toBe(401); - const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, { headers: { Authorization: "Bearer wrong-password" }, }); expect(wrong.status).toBe(401); - const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, { headers: { Authorization: "Bearer correct-password" }, }); - expect(correct.status).toBe(400); + expect(correct.status).toBe(200); + } finally { + await daemonHandle.close(); + } + }); + + test("allows file downloads with only a capability token when password is configured", async () => { + const daemonHandle = await createTestPaseoDaemon({ + auth: { password: CORRECT_PASSWORD_HASH }, + }); + try { + // No bearer at all: the route is reachable, but the download token store + // rejects the request because no token was supplied (400, not 401). + const missingToken = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`); + expect(missingToken.status).toBe(400); + + // An invalid token is rejected by the token store (403, not 401) — proving + // the token, not the daemon password, is what guards this route. + const invalidToken = await fetch( + `http://127.0.0.1:${daemonHandle.port}/api/files/download?token=invalid-token`, + ); + expect(invalidToken.status).toBe(403); } finally { await daemonHandle.close(); }