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.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user