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:
7
.changeset/fix-remote-login-token-leak.md
Normal file
7
.changeset/fix-remote-login-token-leak.md
Normal 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`.
|
||||||
@@ -213,10 +213,19 @@ describe("remote access provider/lifecycle contracts", () => {
|
|||||||
const firstStop = await REQUEST(app, "POST", "/api/remote/tunnel/stop", {});
|
const firstStop = await REQUEST(app, "POST", "/api/remote/tunnel/stop", {});
|
||||||
const secondStop = 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]) {
|
for (const response of [firstStart, secondStart]) {
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual({ state: "starting", provider: "cloudflare" });
|
expect(response.body.state).toBe("stopped");
|
||||||
expect(response.body).toEqual(expect.objectContaining({ state: expect.any(String), provider: expect.any(String) }));
|
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]) {
|
for (const response of [firstStop, secondStop]) {
|
||||||
|
|||||||
134
packages/dashboard/src/__tests__/remote-session.test.ts
Normal file
134
packages/dashboard/src/__tests__/remote-session.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import type { IncomingMessage } from "node:http";
|
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
|
* 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
|
* @param token - The valid bearer token
|
||||||
* @returns Express middleware function
|
* @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 expectedBuffer = Buffer.from(token, "utf8");
|
||||||
const unauthorized = (res: Response): void => {
|
const unauthorized = (res: Response): void => {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
@@ -179,12 +180,25 @@ export function createAuthMiddleware(token: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const providedToken = extractTokenFromRequest(req);
|
const providedToken = extractTokenFromRequest(req);
|
||||||
if (!providedToken) {
|
const tokenAccepted = providedToken !== undefined && constantTimeEqual(providedToken, expectedBuffer);
|
||||||
unauthorized(res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
unauthorized(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
126
packages/dashboard/src/remote-session.ts
Normal file
126
packages/dashboard/src/remote-session.ts
Normal 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("; ");
|
||||||
|
}
|
||||||
@@ -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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import { CliChatSessionRunner } from "./cli-chat.js";
|
|||||||
import { stopAllDevServers } from "./dev-server-routes.js";
|
import { stopAllDevServers } from "./dev-server-routes.js";
|
||||||
import type { SkillsAdapter } from "./skills-adapter.js";
|
import type { SkillsAdapter } from "./skills-adapter.js";
|
||||||
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
|
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
|
||||||
|
import { buildRemoteSessionCookie, createRemoteSessionStore } from "./remote-session.js";
|
||||||
import { setupCliSessionWebSocket } from "./cli-session-ws.js";
|
import { setupCliSessionWebSocket } from "./cli-session-ws.js";
|
||||||
import { createCliSessionsRouter } from "./routes/cli-sessions.js";
|
import { createCliSessionsRouter } from "./routes/cli-sessions.js";
|
||||||
import { getProjectIdFromRequest, resolveStoreForProjectId } from "./routes/context.js";
|
import { getProjectIdFromRequest, resolveStoreForProjectId } from "./routes/context.js";
|
||||||
@@ -1034,8 +1035,15 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
const daemonToken = options?.noAuth
|
const daemonToken = options?.noAuth
|
||||||
? undefined
|
? undefined
|
||||||
: options?.daemon?.token ?? process.env.FUSION_DAEMON_TOKEN;
|
: 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) {
|
if (daemonToken) {
|
||||||
app.use(createAuthMiddleware(daemonToken));
|
app.use(createAuthMiddleware(daemonToken, { validateRemoteSession: (id) => remoteSessions.validate(id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize terminal service with project root
|
// 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) => {
|
app.get("/remote-login", async (req, res) => {
|
||||||
const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined;
|
const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined;
|
||||||
|
|
||||||
@@ -2186,12 +2216,23 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const daemonTokenForRedirect = getDaemonToken(options);
|
/*
|
||||||
if (daemonTokenForRedirect) {
|
FNXC:RemoteAuth 2026-08-19-00:40:
|
||||||
const redirectUrl = new URL("/", `${req.protocol}://${req.get("host")}`);
|
NEVER REDIRECT WITH THE DAEMON TOKEN. This used to hand back `/?token=<daemonToken>`, so anyone
|
||||||
redirectUrl.searchParams.set("token", daemonTokenForRedirect);
|
who opened a shared remote link ended up holding the dashboard's real, non-expiring credential —
|
||||||
res.redirect(302, redirectUrl.pathname + redirectUrl.search);
|
in their URL bar, their history, and any log that records URLs. It also made the remote token
|
||||||
return;
|
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, "/");
|
res.redirect(302, "/");
|
||||||
|
|||||||
Reference in New Issue
Block a user