fix(security): remote login no longer hands over the dashboard token

Sharing a remote link gave the recipient the dashboard's real credential.
`/remote-login?rt=…` validated the remote token and then redirected to
`/?token=<daemonToken>` — 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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-18 17:55:31 -07:00
parent 7423555c46
commit 0e7c353f2b
7 changed files with 366 additions and 16 deletions

View File

@@ -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=<daemonToken>`, 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`.

View File

@@ -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]) {

View File

@@ -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=<daemonToken>`, 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<string, string | undefined>, 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");
});
});

View File

@@ -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=<daemonToken>` — 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;
}

View File

@@ -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=<daemonToken>`.
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<string, number>();
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("; ");
}

View File

@@ -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;
}

View File

@@ -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=<daemonToken>`, 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, "/");