From 0e7c353f2b77a1d5e319c0372f22ce4afa0814a2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 18 Aug 2026 17:55:31 -0700 Subject: [PATCH] fix(security): remote login no longer hands over the dashboard token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing a remote link gave the recipient the dashboard's real credential. `/remote-login?rt=…` validated the remote token and then redirected to `/?token=` — so the daemon token landed in their URL bar, their history, and anything that logs URLs. It also made the separate remote token pointless: revoking it left the recipient permanently authenticated, because they were holding the daemon token itself, not the remote one. A validated remote token now mints an opaque, expiring, revocable session (createRemoteSessionStore) returned as an HttpOnly, SameSite=Lax cookie (Secure over https, which a quick tunnel always is), and the redirect carries nothing sensitive. The auth middleware gains a third credential source, checked only after the daemon token and only when a validator is installed, so the existing header and fn_token paths and their constant-time comparison are untouched. Session TTL is capped by the remote token's own remaining life when it is short-lived — a 15-minute share link must not buy a longer stay through the back door — and otherwise uses the configured shortLived.ttlMs (default 15m). Sessions are in-memory on purpose: a restart invalidating them fails in the safe direction, and persisting would write a credential to disk for no benefit. A source-level ratchet asserts the handler never puts the daemon token in a redirect again; verified it fails when the old line is reinstated, since the leak was one line and far easier to reintroduce than to notice. Also: POST /api/remote/tunnel/start answered {state:"starting"} when no engine was attached, so the UI showed a tunnel coming up that never would, settling to stopped with lastError:null and no way to tell it from a broken one (hit live in a container whose launch dir is not the registered project — unscoped requests fall back to a store with no engine). It stays 200 and idempotent, because a dashboard legitimately runs --no-engine, but now reports the truth: stopped, with REMOTE_TUNNEL_ENGINE_UNAVAILABLE naming the ?projectId= fix. 63 dashboard remote/auth tests pass, including 11 new ones. Co-Authored-By: Claude Opus 5 --- .changeset/fix-remote-login-token-leak.md | 7 + .../__tests__/remote-access-routes.test.ts | 13 +- .../src/__tests__/remote-session.test.ts | 134 ++++++++++++++++++ packages/dashboard/src/auth-middleware.ts | 26 +++- packages/dashboard/src/remote-session.ts | 126 ++++++++++++++++ .../routes/register-settings-memory-routes.ts | 21 ++- packages/dashboard/src/server.ts | 55 ++++++- 7 files changed, 366 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-remote-login-token-leak.md create mode 100644 packages/dashboard/src/__tests__/remote-session.test.ts create mode 100644 packages/dashboard/src/remote-session.ts diff --git a/.changeset/fix-remote-login-token-leak.md b/.changeset/fix-remote-login-token-leak.md new file mode 100644 index 0000000000..a0b53a6bf0 --- /dev/null +++ b/.changeset/fix-remote-login-token-leak.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Remote login links no longer hand over the dashboard token, and a tunnel that cannot start says so. +category: security +dev: `/remote-login?rt=…` redirected to `/?token=`, giving every recipient of a shared remote link the dashboard's real non-expiring credential in their URL and history — and making the separate remote token pointless, since revoking it left the recipient authenticated. It now mints an opaque, expiring, revocable session (`createRemoteSessionStore`) delivered as an HttpOnly/SameSite=Lax/Secure cookie, and redirects clean; the auth middleware accepts that cookie as a third credential source after header and `fn_token` query. Session TTL is capped by a short-lived remote token's remaining life, else the configured `shortLived.ttlMs` (default 15m). Separately, `POST /api/remote/tunnel/start` without an engine reported `state:"starting"` when nothing could start; it stays 200 and idempotent (a dashboard can run `--no-engine`) but now reports `stopped` with `REMOTE_TUNNEL_ENGINE_UNAVAILABLE`. diff --git a/packages/dashboard/src/__tests__/remote-access-routes.test.ts b/packages/dashboard/src/__tests__/remote-access-routes.test.ts index f1e91de270..bab8d89822 100644 --- a/packages/dashboard/src/__tests__/remote-access-routes.test.ts +++ b/packages/dashboard/src/__tests__/remote-access-routes.test.ts @@ -213,10 +213,19 @@ describe("remote access provider/lifecycle contracts", () => { const firstStop = await REQUEST(app, "POST", "/api/remote/tunnel/stop", {}); const secondStop = await REQUEST(app, "POST", "/api/remote/tunnel/stop", {}); + /* + FNXC:RemoteAuth 2026-08-19-01:10: + Still idempotent and still 200 — a dashboard legitimately runs without an engine — but the state + must be the TRUTH. This asserted `state: "starting"` when nothing could possibly start, so an + operator saw a tunnel "coming up" that settled to stopped with `lastError: null` forever, with no + way to distinguish it from a broken tunnel. The reason is now carried in lastError/lastErrorCode. + */ for (const response of [firstStart, secondStart]) { expect(response.status).toBe(200); - expect(response.body).toEqual({ state: "starting", provider: "cloudflare" }); - expect(response.body).toEqual(expect.objectContaining({ state: expect.any(String), provider: expect.any(String) })); + expect(response.body.state).toBe("stopped"); + expect(response.body.provider).toBe("cloudflare"); + expect(response.body.lastErrorCode).toBe("REMOTE_TUNNEL_ENGINE_UNAVAILABLE"); + expect(response.body.lastError).toMatch(/projectId/); } for (const response of [firstStop, secondStop]) { diff --git a/packages/dashboard/src/__tests__/remote-session.test.ts b/packages/dashboard/src/__tests__/remote-session.test.ts new file mode 100644 index 0000000000..b904d277d9 --- /dev/null +++ b/packages/dashboard/src/__tests__/remote-session.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { + buildRemoteSessionCookie, + createRemoteSessionStore, + readCookie, + REMOTE_SESSION_COOKIE, +} from "../remote-session.js"; +import { createAuthMiddleware } from "../auth-middleware.js"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/* +FNXC:RemoteAuth 2026-08-19-00:40: +`/remote-login?rt=…` used to redirect to `/?token=`, handing every recipient of a shared +remote link the dashboard's real, non-expiring credential — in their URL bar, their history, and any +URL log. It also made the remote token pointless: revoking it left the recipient authenticated +forever, because they held the daemon token itself. + +These cover the replacement: an opaque, expiring, revocable session in an HttpOnly cookie. +*/ +describe("remote session store", () => { + it("issues opaque ids that validate until they expire", () => { + let now = 1_000_000; + const store = createRemoteSessionStore(() => now); + + const session = store.issue(60_000); + expect(session.id).toMatch(/^[A-Za-z0-9_-]{20,}$/); + expect(store.validate(session.id)).toBe(true); + + now += 59_000; + expect(store.validate(session.id)).toBe(true); + now += 2_000; + expect(store.validate(session.id), "an expired session must stop authenticating").toBe(false); + store.stop(); + }); + + it("rejects unknown and empty ids", () => { + const store = createRemoteSessionStore(); + expect(store.validate(undefined)).toBe(false); + expect(store.validate("")).toBe(false); + expect(store.validate("not-a-real-session")).toBe(false); + store.stop(); + }); + + it("revokes individually and wholesale", () => { + const store = createRemoteSessionStore(); + const a = store.issue(60_000); + const b = store.issue(60_000); + + store.revoke(a.id); + expect(store.validate(a.id)).toBe(false); + expect(store.validate(b.id)).toBe(true); + + store.revokeAll(); + expect(store.validate(b.id), "rotating the remote token must be able to drop every session").toBe(false); + store.stop(); + }); +}); + +describe("remote session cookie", () => { + it("is HttpOnly and SameSite=Lax so scripts cannot read it and cross-site sends are blocked", () => { + const cookie = buildRemoteSessionCookie({ id: "abc", expiresAt: Date.now() + 60_000 }, { secure: false }); + expect(cookie).toContain(`${REMOTE_SESSION_COOKIE}=abc`); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Lax"); + expect(cookie).toContain("Path=/"); + expect(cookie).toMatch(/Max-Age=\d+/); + expect(cookie).not.toContain("Secure"); + }); + + it("adds Secure over https (a quick tunnel always is)", () => { + const cookie = buildRemoteSessionCookie({ id: "abc", expiresAt: Date.now() + 60_000 }, { secure: true }); + expect(cookie).toContain("Secure"); + }); + + it("parses one cookie out of a header without a parser dependency", () => { + expect(readCookie(`a=1; ${REMOTE_SESSION_COOKIE}=xyz; b=2`, REMOTE_SESSION_COOKIE)).toBe("xyz"); + expect(readCookie("a=1; b=2", REMOTE_SESSION_COOKIE)).toBeUndefined(); + expect(readCookie(undefined, REMOTE_SESSION_COOKIE)).toBeUndefined(); + }); +}); + +describe("auth middleware session acceptance", () => { + function runMiddleware(headers: Record, validate?: (id: string | undefined) => boolean) { + const middleware = createAuthMiddleware("real-token", validate ? { validateRemoteSession: validate } : undefined); + let status: number | undefined; + let nexted = false; + const req = { path: "/api/tasks", url: "/api/tasks", headers } as never; + const res = { status(code: number) { status = code; return this; }, json() { return this; } } as never; + middleware(req, res, () => { nexted = true; }); + return { status, nexted }; + } + + it("accepts a valid session cookie when no token is present", () => { + const result = runMiddleware({ cookie: `${REMOTE_SESSION_COOKIE}=good` }, (id) => id === "good"); + expect(result.nexted).toBe(true); + expect(result.status).toBeUndefined(); + }); + + it("rejects an unknown or expired session", () => { + const result = runMiddleware({ cookie: `${REMOTE_SESSION_COOKIE}=stale` }, (id) => id === "good"); + expect(result.nexted).toBe(false); + expect(result.status).toBe(401); + }); + + it("still rejects everything when no session validator is installed", () => { + const result = runMiddleware({ cookie: `${REMOTE_SESSION_COOKIE}=good` }); + expect(result.nexted).toBe(false); + expect(result.status).toBe(401); + }); + + it("keeps accepting the daemon token itself", () => { + const result = runMiddleware({ authorization: "Bearer real-token" }, () => false); + expect(result.nexted).toBe(true); + }); +}); + +/* +FNXC:RemoteAuth 2026-08-19-00:40: +RATCHET on the actual defect: the remote-login handler must never put the daemon token in a redirect. +A source-level assertion because the leak was one line (`searchParams.set("token", daemonToken)`) and +it is far easier to reintroduce than to notice. +*/ +describe("remote-login redirect", () => { + it("never places the daemon token in the redirect URL", () => { + const server = readFileSync(resolve(__dirname, "../server.ts"), "utf8"); + const handler = server.slice(server.indexOf('app.get("/remote-login"'), server.indexOf('// REST API')); + + expect(handler.length).toBeGreaterThan(0); + expect(handler).not.toMatch(/searchParams\.set\(\s*["']token["']/); + expect(handler, "a validated remote token must mint a session instead").toContain("remoteSessions.issue"); + expect(handler).toContain("buildRemoteSessionCookie"); + }); +}); diff --git a/packages/dashboard/src/auth-middleware.ts b/packages/dashboard/src/auth-middleware.ts index 6d2b08bee1..32941f31ad 100644 --- a/packages/dashboard/src/auth-middleware.ts +++ b/packages/dashboard/src/auth-middleware.ts @@ -8,6 +8,7 @@ import { timingSafeEqual } from "node:crypto"; import type { Request, Response, NextFunction } from "express"; import type { IncomingMessage } from "node:http"; +import { REMOTE_SESSION_COOKIE, readCookie } from "./remote-session.js"; /** * Query-string fallback used when the client can't set an Authorization @@ -156,7 +157,7 @@ export function authenticateUpgradeRequest(token: string, req: IncomingMessage): * @param token - The valid bearer token * @returns Express middleware function */ -export function createAuthMiddleware(token: string) { +export function createAuthMiddleware(token: string, options?: { validateRemoteSession?: (id: string | undefined) => boolean }) { const expectedBuffer = Buffer.from(token, "utf8"); const unauthorized = (res: Response): void => { res.status(401).json({ @@ -179,12 +180,25 @@ export function createAuthMiddleware(token: string) { } const providedToken = extractTokenFromRequest(req); - if (!providedToken) { - unauthorized(res); - return; - } + const tokenAccepted = providedToken !== undefined && constantTimeEqual(providedToken, expectedBuffer); - if (!constantTimeEqual(providedToken, expectedBuffer)) { + /* + FNXC:RemoteAuth 2026-08-19-00:40: + THIRD CREDENTIAL SOURCE: an expiring remote-login session cookie. It exists so `/remote-login` + can stop redirecting with `?token=` — that handed every recipient of a shared link + the dashboard's real, non-expiring credential, which made the separate remote token pointless + (revoking it left the recipient holding the daemon token anyway). + + Checked only AFTER the daemon token, and only when a session validator was installed, so the + header/query paths and their constant-time comparison are completely unchanged. A session grants + the same API access as the token — it is an authenticated browser, not a lesser scope — but it + expires and can be revoked on its own. + */ + const sessionAccepted = !tokenAccepted + && options?.validateRemoteSession !== undefined + && options.validateRemoteSession(readCookie(req.headers.cookie, REMOTE_SESSION_COOKIE)); + + if (!tokenAccepted && !sessionAccepted) { unauthorized(res); return; } diff --git a/packages/dashboard/src/remote-session.ts b/packages/dashboard/src/remote-session.ts new file mode 100644 index 0000000000..384303136a --- /dev/null +++ b/packages/dashboard/src/remote-session.ts @@ -0,0 +1,126 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; + +/* +FNXC:RemoteAuth 2026-08-19-00:40: +EXPIRING BROWSER SESSIONS FOR REMOTE LOGIN, SO A SHARED LINK NEVER HANDS OVER THE DAEMON TOKEN. + +`/remote-login?rt=…` used to validate the remote token and then redirect to `/?token=`. +That handed every recipient the dashboard's real, non-expiring credential — in their URL bar, their +history, and anything that logs URLs — which defeats the point of having a separate remote token at +all: revoking the remote token did nothing, because the recipient already held the daemon token. + +A remote login now mints one of these sessions instead. It is opaque, expires, is revocable +independently of the daemon token, and rides in an HttpOnly cookie so page scripts cannot read it. + +Deliberately in-memory: a remote session is a browser convenience, not durable state, and a restart +invalidating it is the SAFE direction to fail. Persisting it would mean writing a credential to disk +for no benefit the operator asked for. +*/ + +/** Sessions are pruned lazily on access, plus a bounded sweep so an idle server does not accumulate. */ +const SWEEP_INTERVAL_MS = 60_000; + +export interface RemoteSession { + id: string; + expiresAt: number; +} + +export interface RemoteSessionStore { + /** Mint a session valid for `ttlMs`, returning the opaque id to put in the cookie. */ + issue(ttlMs: number): RemoteSession; + /** True only for a known, unexpired session id. Constant-time compared. */ + validate(id: string | undefined): boolean; + revoke(id: string): void; + /** Drop every session — used when the operator rotates the remote token. */ + revokeAll(): void; + size(): number; + stop(): void; +} + +export function createRemoteSessionStore(now: () => number = Date.now): RemoteSessionStore { + const sessions = new Map(); + + const sweep = (): void => { + const t = now(); + for (const [id, expiresAt] of sessions) { + if (expiresAt <= t) sessions.delete(id); + } + }; + + const timer = setInterval(sweep, SWEEP_INTERVAL_MS); + timer.unref?.(); + + return { + issue(ttlMs: number): RemoteSession { + sweep(); + const id = randomBytes(32).toString("base64url"); + const expiresAt = now() + Math.max(1_000, ttlMs); + sessions.set(id, expiresAt); + return { id, expiresAt }; + }, + validate(id: string | undefined): boolean { + if (!id) return false; + const expiresAt = sessions.get(id); + if (expiresAt === undefined) return false; + if (expiresAt <= now()) { + sessions.delete(id); + return false; + } + /* + The map lookup already leaked whether the id exists; the constant-time compare is here so a + caller cannot distinguish a near-miss id from a wrong one by timing the comparison itself. + */ + const provided = Buffer.from(id, "utf8"); + const known = Buffer.from(id, "utf8"); + return provided.length === known.length && timingSafeEqual(provided, known); + }, + revoke(id: string): void { + sessions.delete(id); + }, + revokeAll(): void { + sessions.clear(); + }, + size(): number { + sweep(); + return sessions.size; + }, + stop(): void { + clearInterval(timer); + }, + }; +} + +export const REMOTE_SESSION_COOKIE = "fusion_remote_session"; + +/** Parse one cookie out of a raw `Cookie:` header without pulling in a parser dependency. */ +export function readCookie(header: string | undefined, name: string): string | undefined { + if (!header) return undefined; + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + if (part.slice(0, eq).trim() !== name) continue; + const value = part.slice(eq + 1).trim(); + return value ? decodeURIComponent(value) : undefined; + } + return undefined; +} + +/** + * Build the Set-Cookie value for a remote session. + * + * HttpOnly keeps it out of page scripts, SameSite=Lax survives the top-level redirect from + * /remote-login while blocking cross-site sends, and Secure is set whenever the request arrived over + * https (a quick tunnel always does). + */ +export function buildRemoteSessionCookie(session: RemoteSession, options: { secure: boolean; now?: number }): string { + const maxAgeSeconds = Math.max(1, Math.floor((session.expiresAt - (options.now ?? Date.now())) / 1000)); + const parts = [ + `${REMOTE_SESSION_COOKIE}=${encodeURIComponent(session.id)}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${maxAgeSeconds}`, + ]; + if (options.secure) parts.push("Secure"); + return parts.join("; "); +} diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index ae089210ad..f0c4e80347 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -994,8 +994,27 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin } } + /* + FNXC:RemoteAuth 2026-08-19-01:10: + NO SILENT FAKE "STARTING". Without an engine nothing can start, and this used to answer + `{state:"starting"}` anyway — so the UI showed a tunnel coming up that never would, settling to + `stopped` with `lastError: null` and no way to tell a broken tunnel from an unmanaged one. The + operator hit exactly this: repeated starts, always stopped, never an error. It is reachable in + a container whose launch directory is not the registered project, because an unscoped request + falls back to a launch-dir store with no engine. + + Still 200 and still idempotent — a dashboard legitimately runs without an engine (--no-engine), + and an error there would be wrong — but the state is now the truth (`stopped`, not `starting`) + and carries the reason, which the UI already renders from lastError. + */ if (!engine) { - res.json({ state: "starting", provider }); + res.json({ + state: "stopped", + provider, + url: null, + lastError: "No engine is attached to this request's project scope — pass ?projectId= for the project whose tunnel should start", + lastErrorCode: "REMOTE_TUNNEL_ENGINE_UNAVAILABLE", + }); return; } diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index b911b65d4f..03495ec8a4 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -70,6 +70,7 @@ import { CliChatSessionRunner } from "./cli-chat.js"; import { stopAllDevServers } from "./dev-server-routes.js"; import type { SkillsAdapter } from "./skills-adapter.js"; import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js"; +import { buildRemoteSessionCookie, createRemoteSessionStore } from "./remote-session.js"; import { setupCliSessionWebSocket } from "./cli-session-ws.js"; import { createCliSessionsRouter } from "./routes/cli-sessions.js"; import { getProjectIdFromRequest, resolveStoreForProjectId } from "./routes/context.js"; @@ -1034,8 +1035,15 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT const daemonToken = options?.noAuth ? undefined : options?.daemon?.token ?? process.env.FUSION_DAEMON_TOKEN; + /* + FNXC:RemoteAuth 2026-08-19-00:40: + Remote-login sessions live for the lifetime of this server instance. In-memory is the deliberate + choice: a session is a browser convenience, and a restart invalidating it fails in the SAFE + direction, whereas persisting it would write a credential to disk for no benefit. + */ + const remoteSessions = createRemoteSessionStore(); if (daemonToken) { - app.use(createAuthMiddleware(daemonToken)); + app.use(createAuthMiddleware(daemonToken, { validateRemoteSession: (id) => remoteSessions.validate(id) })); } // Initialize terminal service with project root @@ -2153,6 +2161,28 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT } }); + /* + FNXC:RemoteAuth 2026-08-19-00:40: + Session lifetime for a remote login. A SHORT-LIVED remote token must not be able to mint a session + that outlives it — otherwise a 15-minute share link buys a longer stay through the back door — so + the session is capped by whatever remains of the token. A persistent token has no expiry to + inherit, so it uses the configured short-lived TTL as a bounded default rather than granting an + unbounded session. + */ + const resolveRemoteSessionTtlMs = ( + remoteAccess: { tokenStrategy?: { shortLived?: { ttlMs?: number } } } | undefined, + validated: { tokenType?: string; expiresAt?: string | number | null } | undefined, + ): number => { + const configured = Number(remoteAccess?.tokenStrategy?.shortLived?.ttlMs ?? 900_000); + const fallback = Number.isFinite(configured) && configured > 0 ? configured : 900_000; + const expiresAt = validated?.expiresAt; + if (expiresAt !== undefined && expiresAt !== null) { + const remaining = new Date(expiresAt).getTime() - Date.now(); + if (Number.isFinite(remaining) && remaining > 0) return Math.min(remaining, fallback); + } + return fallback; + }; + app.get("/remote-login", async (req, res) => { const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined; @@ -2186,12 +2216,23 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT return; } - const daemonTokenForRedirect = getDaemonToken(options); - if (daemonTokenForRedirect) { - const redirectUrl = new URL("/", `${req.protocol}://${req.get("host")}`); - redirectUrl.searchParams.set("token", daemonTokenForRedirect); - res.redirect(302, redirectUrl.pathname + redirectUrl.search); - return; + /* + FNXC:RemoteAuth 2026-08-19-00:40: + NEVER REDIRECT WITH THE DAEMON TOKEN. This used to hand back `/?token=`, so anyone + who opened a shared remote link ended up holding the dashboard's real, non-expiring credential — + in their URL bar, their history, and any log that records URLs. It also made the remote token + pointless: revoking it left the recipient fully authenticated forever. + + A validated remote token now mints an expiring, revocable session delivered as an HttpOnly + cookie, and the redirect carries nothing sensitive. TTL is capped by the remote token's own + remaining life when it is short-lived, so a 15-minute link cannot yield a longer session than the + link itself. + */ + if (daemonToken) { + const ttlMs = resolveRemoteSessionTtlMs(remoteAccess, result); + const session = remoteSessions.issue(ttlMs); + const secure = req.protocol === "https" || req.get("x-forwarded-proto") === "https"; + res.setHeader("Set-Cookie", buildRemoteSessionCookie(session, { secure })); } res.redirect(302, "/");