From e373622b12ff4086175fc807c402cf1bdeb2afb7 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 2 Jul 2026 21:13:51 -0700 Subject: [PATCH] Secure custom image terminal websocket auth Co-Authored-By: Paperclip --- .../environment-custom-image-routes.test.ts | 2 + ...vironment-custom-image-terminal-ws.test.ts | 78 ++-- .../environment-custom-image-terminal-ws.ts | 377 ++++++++++++------ server/src/routes/environments.ts | 2 +- ...ent-custom-image-terminal-sessions.test.ts | 37 ++ ...ironment-custom-image-terminal-sessions.ts | 24 ++ ui/src/pages/CompanyEnvironments.test.tsx | 8 +- ui/src/pages/CompanyEnvironments.tsx | 1 + 8 files changed, 377 insertions(+), 152 deletions(-) diff --git a/server/src/__tests__/environment-custom-image-routes.test.ts b/server/src/__tests__/environment-custom-image-routes.test.ts index ef416a948bf..ae20e425261 100644 --- a/server/src/__tests__/environment-custom-image-routes.test.ts +++ b/server/src/__tests__/environment-custom-image-routes.test.ts @@ -366,6 +366,8 @@ describe("environment customImage setup routes", () => { expect(res.body.websocketPath).toContain( `/api/environment-custom-image-setup-sessions/session-1/terminal/ws?terminalSessionId=${encodeURIComponent(res.body.id)}`, ); + expect(res.body.websocketPath).not.toContain("token="); + expect(res.body.websocketPath).not.toContain(res.body.token); expect(mockEnvironmentCustomImageService.refreshSetupSession).toHaveBeenCalledWith({ sessionId: "session-1", includeConnectionPayload: true, diff --git a/server/src/__tests__/environment-custom-image-terminal-ws.test.ts b/server/src/__tests__/environment-custom-image-terminal-ws.test.ts index d8e3400e684..20122674c40 100644 --- a/server/src/__tests__/environment-custom-image-terminal-ws.test.ts +++ b/server/src/__tests__/environment-custom-image-terminal-ws.test.ts @@ -142,12 +142,6 @@ function waitForOpen(ws: InstanceType) { }); } -function waitForError(ws: InstanceType) { - return new Promise((resolve) => { - ws.on("error", resolve); - }); -} - function waitForClose(ws: InstanceType) { return new Promise((resolve) => { ws.on("close", resolve); @@ -167,10 +161,14 @@ function waitForJsonMessage>( }); } -function terminalUrl(port: number, input: { setupSessionId?: string; terminalSessionId: string; token: string }) { +function sendTerminalAuth(ws: InstanceType, token: string) { + ws.send(JSON.stringify({ type: "auth", token })); +} + +function terminalUrl(port: number, input: { setupSessionId?: string; terminalSessionId: string }) { const setupSessionId = input.setupSessionId ?? "session-1"; return `ws://127.0.0.1:${port}/api/environment-custom-image-setup-sessions/${setupSessionId}/terminal/ws` - + `?terminalSessionId=${encodeURIComponent(input.terminalSessionId)}&token=${encodeURIComponent(input.token)}` + + `?terminalSessionId=${encodeURIComponent(input.terminalSessionId)}` + "&cols=100&rows=30"; } @@ -230,11 +228,17 @@ describe("custom image terminal websocket bridge", () => { const { port } = await startHarness(); const invalid = new WebSocket(terminalUrl(port, { terminalSessionId: "missing", - token: "bad-token", })); - const invalidError = await waitForError(invalid); - expect(invalidError.message).toContain("422"); + const invalidError = waitForJsonMessage(invalid, (frame) => frame.type === "error"); + const invalidClose = waitForClose(invalid); + await waitForOpen(invalid); + sendTerminalAuth(invalid, "bad-token"); + await expect(invalidError).resolves.toMatchObject({ + type: "error", + message: "Invalid terminal session token.", + }); + await invalidClose; expect(customImages.refreshSetupSession).not.toHaveBeenCalled(); const expired = sessionStore.create({ @@ -249,11 +253,17 @@ describe("custom image terminal websocket bridge", () => { }); const expiredWs = new WebSocket(terminalUrl(port, { terminalSessionId: expired.session.id, - token: expired.token, })); - const expiredError = await waitForError(expiredWs); - expect(expiredError.message).toContain("422"); + const expiredError = waitForJsonMessage(expiredWs, (frame) => frame.type === "error"); + const expiredClose = waitForClose(expiredWs); + await waitForOpen(expiredWs); + sendTerminalAuth(expiredWs, expired.token); + await expect(expiredError).resolves.toMatchObject({ + type: "error", + message: "Invalid terminal session token.", + }); + await expiredClose; expect(customImages.refreshSetupSession).not.toHaveBeenCalled(); }); @@ -277,10 +287,16 @@ describe("custom image terminal websocket bridge", () => { const unsupportedWs = new WebSocket(terminalUrl(port, { terminalSessionId: unsupportedPayload.session.id, - token: unsupportedPayload.token, })); - const unsupportedError = await waitForError(unsupportedWs); - expect(unsupportedError.message).toContain("422"); + const unsupportedError = waitForJsonMessage(unsupportedWs, (frame) => frame.type === "error"); + const unsupportedClose = waitForClose(unsupportedWs); + await waitForOpen(unsupportedWs); + sendTerminalAuth(unsupportedWs, unsupportedPayload.token); + await expect(unsupportedError).resolves.toMatchObject({ + type: "error", + message: "Setup session terminal connections require an SSH connection payload.", + }); + await unsupportedClose; expect(sessionStore.get({ id: unsupportedPayload.session.id, token: unsupportedPayload.token, @@ -304,10 +320,16 @@ describe("custom image terminal websocket bridge", () => { const unsupportedCommandWs = new WebSocket(terminalUrl(port, { terminalSessionId: unsupportedCommand.session.id, - token: unsupportedCommand.token, })); - const unsupportedCommandError = await waitForError(unsupportedCommandWs); - expect(unsupportedCommandError.message).toContain("422"); + const unsupportedCommandError = waitForJsonMessage(unsupportedCommandWs, (frame) => frame.type === "error"); + const unsupportedCommandClose = waitForClose(unsupportedCommandWs); + await waitForOpen(unsupportedCommandWs); + sendTerminalAuth(unsupportedCommandWs, unsupportedCommand.token); + await expect(unsupportedCommandError).resolves.toMatchObject({ + type: "error", + message: "Setup session SSH payload uses an unsupported command shape.", + }); + await unsupportedCommandClose; expect(sessionStore.get({ id: unsupportedCommand.session.id, token: unsupportedCommand.token, @@ -327,11 +349,11 @@ describe("custom image terminal websocket bridge", () => { }); const ws = new WebSocket(terminalUrl(port, { terminalSessionId: minted.session.id, - token: minted.token, })); const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready"); await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); const ready = await readyPromise; expect(ready).toMatchObject({ type: "ready", @@ -347,6 +369,7 @@ describe("custom image terminal websocket bridge", () => { term: "xterm-256color", cols: 100, rows: 30, + verifyHostKeySha256: expect.any(Function), }); ws.send(JSON.stringify({ type: "input", data: "echo ok\r" })); @@ -400,7 +423,6 @@ describe("custom image terminal websocket bridge", () => { const ws = new WebSocket(terminalUrl(port, { terminalSessionId: minted.session.id, - token: minted.token, })); let closed = false; ws.on("close", () => { @@ -410,6 +432,7 @@ describe("custom image terminal websocket bridge", () => { const closePromise = waitForClose(ws); await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); await readyPromise; await waitForDuration(Math.max(0, minted.session.connectExpiresAt.getTime() - Date.now()) + 400); expect(closed).toBe(false); @@ -434,11 +457,14 @@ describe("custom image terminal websocket bridge", () => { }); const ws = new WebSocket(terminalUrl(port, { terminalSessionId: minted.session.id, - token: minted.token, })); const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready"); await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); + await waitForAssertion(() => { + expect(connector.connect).toHaveBeenCalled(); + }); const unsupportedFramePromise = waitForJsonMessage(ws, (frame) => frame.type === "error"); ws.send(JSON.stringify({ type: "resize", cols: 110, rows: 31 })); ws.send(JSON.stringify({ type: "resize", cols: 132, rows: 43 })); @@ -467,12 +493,12 @@ describe("custom image terminal websocket bridge", () => { }); const ws = new WebSocket(terminalUrl(port, { terminalSessionId: minted.session.id, - token: minted.token, })); const errorPromise = waitForJsonMessage(ws, (frame) => frame.type === "error"); const closePromise = waitForClose(ws); await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); await expect(errorPromise).resolves.toMatchObject({ type: "error", message: "SSH terminal connection failed.", @@ -493,11 +519,11 @@ describe("custom image terminal websocket bridge", () => { }); const ws = new WebSocket(terminalUrl(port, { terminalSessionId: minted.session.id, - token: minted.token, })); const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready"); await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); await readyPromise; const errorPromise = waitForJsonMessage(ws, (frame) => frame.type === "error"); const closePromise = waitForClose(ws); @@ -523,11 +549,11 @@ describe("custom image terminal websocket bridge", () => { }); const ws = new WebSocket(terminalUrl(port, { terminalSessionId: minted.session.id, - token: minted.token, })); const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready"); await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); await readyPromise; const closePromise = waitForClose(ws); server.emit("close"); diff --git a/server/src/realtime/environment-custom-image-terminal-ws.ts b/server/src/realtime/environment-custom-image-terminal-ws.ts index 31bd599152e..702ee60d19b 100644 --- a/server/src/realtime/environment-custom-image-terminal-ws.ts +++ b/server/src/realtime/environment-custom-image-terminal-ws.ts @@ -76,10 +76,18 @@ export interface EnvironmentCustomImageSshConnector { term: string; cols: number; rows: number; + verifyHostKeySha256: (hostKeySha256: string) => boolean; }): Promise; } interface TerminalUpgradeContext { + setupSessionId: string; + terminalSessionId: string; + initialCols: number; + initialRows: number; +} + +interface AuthenticatedTerminalContext { setupSessionId: string; terminalSession: EnvironmentCustomImageTerminalSessionRecord; ssh: ParsedCustomImageSetupSshCommand; @@ -101,6 +109,7 @@ const CUSTOM_IMAGE_TERMINAL_UTF8_ENV = { LANG: "C.UTF-8", LC_CTYPE: "C.UTF-8", }; +const TERMINAL_AUTH_TIMEOUT_MS = 10_000; function isWritableUpgradeSocket(socket: Duplex) { const maybeWritableState = socket as Duplex & { writable?: boolean; writableEnded?: boolean; writableDestroyed?: boolean }; @@ -171,23 +180,37 @@ function readSetupSessionCompanyId(session: { return companyId && companyId !== "instance" ? companyId : null; } -function statusLineForError(err: unknown) { - const status = typeof err === "object" && err !== null && "status" in err - ? Number((err as { status?: unknown }).status) - : 500; - if (status === 400) return "400 Bad Request"; - if (status === 401) return "401 Unauthorized"; - if (status === 403) return "403 Forbidden"; - if (status === 404) return "404 Not Found"; - if (status === 409) return "409 Conflict"; - if (status === 422) return "422 Unprocessable Entity"; - return "500 Internal Server Error"; -} - function safeErrorName(err: unknown) { return err instanceof Error ? err.name : typeof err; } +function errorStatus(err: unknown) { + return typeof err === "object" && err !== null && "status" in err + ? Number((err as { status?: unknown }).status) + : 500; +} + +function clientSafeErrorMessage(err: unknown, fallback: string) { + const status = errorStatus(err); + if ([400, 401, 403, 404, 409, 422].includes(status) && err instanceof Error && err.message) { + return err.message; + } + return fallback; +} + +function safeUpgradePath(rawUrl: string | undefined): string | undefined { + if (!rawUrl) return undefined; + try { + const url = new URL(rawUrl, "http://localhost"); + if (url.searchParams.has("token")) { + url.searchParams.set("token", "[redacted]"); + } + return `${url.pathname}${url.search}`; + } catch { + return rawUrl.split("?")[0] || undefined; + } +} + function terminalPayloadValidationError( failure: Extract, ): Error { @@ -212,6 +235,50 @@ function closeClient(socket: TerminalWsSocket, code: number, reason: string) { socket.close(code, reason.slice(0, 120)); } +function readResizeDimensions(frame: Record): { cols: number; rows: number } | null { + const cols = typeof frame.cols === "number" && Number.isInteger(frame.cols) ? frame.cols : null; + const rows = typeof frame.rows === "number" && Number.isInteger(frame.rows) ? frame.rows : null; + if ( + cols !== null + && rows !== null + && cols > 0 + && rows > 0 + && cols <= 9999 + && rows <= 9999 + ) { + return { cols, rows }; + } + return null; +} + +function parseJsonClientFrame(raw: unknown): Record | null { + const text = decodeClientMessage(raw); + if (!text) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : null; +} + +function readAuthTokenFrame(raw: unknown): string | null { + const frame = parseJsonClientFrame(raw); + if (!frame || frame.type !== "auth" || typeof frame.token !== "string") return null; + const token = frame.token.trim(); + return token || null; +} + +function readPreAuthResizeFrame(raw: unknown): { cols: number; rows: number } | null { + const frame = parseJsonClientFrame(raw); + return frame?.type === "resize" ? readResizeDimensions(frame) : null; +} + function handleClientFrame( socket: TerminalWsSocket, shell: EnvironmentCustomImageSshShell | null, @@ -240,20 +307,12 @@ function handleClientFrame( return; } if (frame.type === "resize") { - const cols = typeof frame.cols === "number" && Number.isInteger(frame.cols) ? frame.cols : null; - const rows = typeof frame.rows === "number" && Number.isInteger(frame.rows) ? frame.rows : null; - if ( - cols !== null - && rows !== null - && cols > 0 - && rows > 0 - && cols <= 9999 - && rows <= 9999 - ) { + const dimensions = readResizeDimensions(frame); + if (dimensions) { if (shell) { - shell.resize(cols, rows); + shell.resize(dimensions.cols, dimensions.rows); } else { - onPendingResize?.({ cols, rows }); + onPendingResize?.(dimensions); } } return; @@ -380,7 +439,7 @@ class Ssh2Shell implements EnvironmentCustomImageSshShell { export function createSsh2EnvironmentCustomImageSshConnector(): EnvironmentCustomImageSshConnector { return { - connect: async ({ ssh, term, cols, rows }) => { + connect: async ({ ssh, term, cols, rows, verifyHostKeySha256 }) => { const { Client } = require("ssh2") as { Client: new () => { once(event: "ready", listener: () => void): void; @@ -428,6 +487,8 @@ export function createSsh2EnvironmentCustomImageSshConnector(): EnvironmentCusto host: ssh.host, port: ssh.port, username: ssh.username, + hostHash: "sha256", + hostVerifier: (hostKeySha256: string) => verifyHostKeySha256(hostKeySha256), readyTimeout: 20000, keepaliveInterval: 15000, keepaliveCountMax: 3, @@ -457,32 +518,41 @@ export function setupEnvironmentCustomImageTerminalWebSocketServer( const sshConnector = opts.sshConnector ?? createSsh2EnvironmentCustomImageSshConnector(); wss.on("connection", (socket: TerminalWsSocket, req: IncomingMessage) => { - const context = (req as IncomingMessageWithTerminalContext).paperclipTerminalUpgradeContext; - if (!context) { + const upgradeContext = (req as IncomingMessageWithTerminalContext).paperclipTerminalUpgradeContext; + if (!upgradeContext) { socket.close(1008, "missing context"); return; } let shell: EnvironmentCustomImageSshShell | null = null; let pendingResize: { cols: number; rows: number } | null = null; + let preAuthResize: { cols: number; rows: number } | null = null; let cleanupRegistry: (() => void) | null = null; + let authenticatedContext: AuthenticatedTerminalContext | null = null; + let authenticating = false; + let authenticated = false; let expiryTimer: ReturnType | null = null; + let authTimer: ReturnType | null = null; let cleanedUp = false; const cleanup = (reason: string) => { if (cleanedUp) return; cleanedUp = true; + if (authTimer) clearTimeout(authTimer); if (expiryTimer) clearTimeout(expiryTimer); cleanupRegistry?.(); cleanupRegistry = null; - sessionStore.delete(context.terminalSession.id); + const terminalSessionId = authenticatedContext?.terminalSession.id ?? upgradeContext.terminalSessionId; + if (authenticatedContext) { + sessionStore.delete(authenticatedContext.terminalSession.id); + } if (shell) { shell.close(); shell = null; } logger.info({ - setupSessionId: context.setupSessionId, - terminalSessionId: context.terminalSession.id, + setupSessionId: upgradeContext.setupSessionId, + terminalSessionId, reason, }, "custom image terminal websocket closed"); }; @@ -493,19 +563,149 @@ export function setupEnvironmentCustomImageTerminalWebSocketServer( cleanup(reason); }; - cleanupRegistry = connectionRegistry.add({ - setupSessionId: context.setupSessionId, - close: (reason) => { - closeTerminal(reason); - }, - }); + const startAuthenticatedTerminal = (context: AuthenticatedTerminalContext) => { + authenticatedContext = context; + if (authTimer) { + clearTimeout(authTimer); + authTimer = null; + } + if (preAuthResize) { + pendingResize = preAuthResize; + preAuthResize = null; + } - const expiresInMs = Math.max(0, context.terminalSession.sessionExpiresAt.getTime() - Date.now()); - expiryTimer = setTimeout(() => { - closeTerminal("expired", 1008, "expired"); - }, expiresInMs); + cleanupRegistry = connectionRegistry.add({ + setupSessionId: context.setupSessionId, + close: (reason) => { + closeTerminal(reason); + }, + }); + + const expiresInMs = Math.max(0, context.terminalSession.sessionExpiresAt.getTime() - Date.now()); + expiryTimer = setTimeout(() => { + closeTerminal("expired", 1008, "expired"); + }, expiresInMs); + + void sshConnector.connect({ + ssh: context.ssh, + term: "xterm-256color", + cols: context.initialCols, + rows: context.initialRows, + verifyHostKeySha256: (hostKeySha256) => sessionStore.verifyOrPinHostKey({ + id: context.terminalSession.id, + hostKeySha256, + }), + }) + .then((connectedShell) => { + if (cleanedUp) { + connectedShell.close(); + return; + } + shell = connectedShell; + if (pendingResize) { + shell.resize(pendingResize.cols, pendingResize.rows); + pendingResize = null; + } + shell.onData((data) => { + sendJson(socket, { type: "output", data }); + }); + shell.onClose(() => { + if (cleanedUp) return; + closeTerminal("ssh_closed"); + }); + shell.onError((err) => { + if (cleanedUp) return; + logger.warn({ + errorName: safeErrorName(err), + setupSessionId: context.setupSessionId, + terminalSessionId: context.terminalSession.id, + }, "custom image terminal ssh stream failed"); + sendJson(socket, { type: "error", message: "SSH terminal connection failed." }); + closeClient(socket, 1011, "ssh error"); + cleanup("ssh_error"); + }); + sendJson(socket, { + type: "ready", + setupSessionId: context.setupSessionId, + terminalSessionId: context.terminalSession.id, + }); + }) + .catch((err) => { + logger.warn({ + errorName: safeErrorName(err), + setupSessionId: context.setupSessionId, + terminalSessionId: context.terminalSession.id, + }, "custom image terminal ssh connection failed"); + sendJson(socket, { type: "error", message: "SSH terminal connection failed." }); + closeClient(socket, 1011, "ssh error"); + cleanup("ssh_connect_error"); + }); + }; + + authTimer = setTimeout(() => { + sendJson(socket, { type: "error", message: "Terminal authentication timed out." }); + closeClient(socket, 1008, "terminal auth timeout"); + cleanup("auth_timeout"); + }, TERMINAL_AUTH_TIMEOUT_MS); socket.on("message", (data: unknown) => { + if (!authenticated) { + const resize = readPreAuthResizeFrame(data); + if (resize) { + preAuthResize = resize; + return; + } + + const token = readAuthTokenFrame(data); + if (!token) { + sendJson(socket, { type: "error", message: "Terminal authentication is required." }); + closeClient(socket, 1008, "terminal auth required"); + cleanup("auth_frame_invalid"); + return; + } + if (authenticating) return; + authenticating = true; + + void validateTerminalUpgrade({ + setupSessionId: upgradeContext.setupSessionId, + terminalSessionId: upgradeContext.terminalSessionId, + token, + now: new Date(), + sessionStore, + customImages, + }) + .then((terminalSession) => { + authenticating = false; + if (cleanedUp || socket.readyState !== WebSocket.OPEN) { + sessionStore.delete(terminalSession.id); + return; + } + authenticated = true; + startAuthenticatedTerminal({ + setupSessionId: upgradeContext.setupSessionId, + terminalSession, + ssh: terminalSession.ssh, + initialCols: upgradeContext.initialCols, + initialRows: upgradeContext.initialRows, + }); + }) + .catch((err) => { + authenticating = false; + logger.warn({ + errorName: safeErrorName(err), + setupSessionId: upgradeContext.setupSessionId, + terminalSessionId: upgradeContext.terminalSessionId, + }, "custom image terminal websocket authentication rejected"); + sendJson(socket, { + type: "error", + message: clientSafeErrorMessage(err, "Terminal authentication failed."), + }); + closeClient(socket, errorStatus(err) >= 500 ? 1011 : 1008, "terminal auth rejected"); + cleanup("auth_rejected"); + }); + return; + } + handleClientFrame(socket, shell, data, (dimensions) => { pendingResize = dimensions; }); @@ -518,57 +718,6 @@ export function setupEnvironmentCustomImageTerminalWebSocketServer( socket.on("error", () => { cleanup("client_error"); }); - - void sshConnector.connect({ - ssh: context.ssh, - term: "xterm-256color", - cols: context.initialCols, - rows: context.initialRows, - }) - .then((connectedShell) => { - if (cleanedUp) { - connectedShell.close(); - return; - } - shell = connectedShell; - if (pendingResize) { - shell.resize(pendingResize.cols, pendingResize.rows); - pendingResize = null; - } - shell.onData((data) => { - sendJson(socket, { type: "output", data }); - }); - shell.onClose(() => { - if (cleanedUp) return; - closeTerminal("ssh_closed"); - }); - shell.onError((err) => { - if (cleanedUp) return; - logger.warn({ - errorName: safeErrorName(err), - setupSessionId: context.setupSessionId, - terminalSessionId: context.terminalSession.id, - }, "custom image terminal ssh stream failed"); - sendJson(socket, { type: "error", message: "SSH terminal connection failed." }); - closeClient(socket, 1011, "ssh error"); - cleanup("ssh_error"); - }); - sendJson(socket, { - type: "ready", - setupSessionId: context.setupSessionId, - terminalSessionId: context.terminalSession.id, - }); - }) - .catch((err) => { - logger.warn({ - errorName: safeErrorName(err), - setupSessionId: context.setupSessionId, - terminalSessionId: context.terminalSession.id, - }, "custom image terminal ssh connection failed"); - sendJson(socket, { type: "error", message: "SSH terminal connection failed." }); - closeClient(socket, 1011, "ssh error"); - cleanup("ssh_connect_error"); - }); }); wss.on("close", () => { @@ -596,9 +745,10 @@ export function setupEnvironmentCustomImageTerminalWebSocketServer( if (!path) return; reqWithContext.paperclipWebSocketHandled = true; + const logPath = safeUpgradePath(req.url); const onRawSocketError = (err: Error) => { - logger.warn({ errorName: safeErrorName(err), path: req.url }, "custom image terminal websocket upgrade socket error"); + logger.warn({ errorName: safeErrorName(err), path: logPath }, "custom image terminal websocket upgrade socket error"); }; const cleanupRawSocketListeners = () => { socket.off("error", onRawSocketError); @@ -609,45 +759,24 @@ export function setupEnvironmentCustomImageTerminalWebSocketServer( socket.once("close", cleanupRawSocketListeners); const terminalSessionId = url.searchParams.get("terminalSessionId")?.trim() ?? ""; - const token = url.searchParams.get("token")?.trim() ?? ""; const initialCols = parseTerminalDimension(url.searchParams.get("cols"), 80); const initialRows = parseTerminalDimension(url.searchParams.get("rows"), 24); - if (!terminalSessionId || !token) { - rejectUpgrade(socket, "400 Bad Request", "missing terminal credentials"); + if (!terminalSessionId) { + rejectUpgrade(socket, "400 Bad Request", "missing terminal session"); return; } - void validateTerminalUpgrade({ + reqWithContext.paperclipTerminalUpgradeContext = { setupSessionId: path.setupSessionId, terminalSessionId, - token, - now: new Date(), - sessionStore, - customImages, - }) - .then((terminalSession) => { - if (!isWritableUpgradeSocket(socket)) { - cleanupRawSocketListeners(); - return; - } + initialCols, + initialRows, + }; - reqWithContext.paperclipTerminalUpgradeContext = { - setupSessionId: path.setupSessionId, - terminalSession, - ssh: terminalSession.ssh, - initialCols, - initialRows, - }; - - cleanupRawSocketListeners(); - wss.handleUpgrade(req, socket, head, (ws: TerminalWsSocket) => { - wss.emit("connection", ws, reqWithContext); - }); - }) - .catch((err) => { - logger.warn({ errorName: safeErrorName(err), path: req.url }, "custom image terminal websocket upgrade rejected"); - rejectUpgrade(socket, statusLineForError(err), "terminal websocket upgrade rejected"); - }); + cleanupRawSocketListeners(); + wss.handleUpgrade(req, socket, head, (ws: TerminalWsSocket) => { + wss.emit("connection", ws, reqWithContext); + }); }); return wss; diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 269609d3495..6ff5d49d3fa 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -500,7 +500,7 @@ export function environmentRoutes( connectionType: "ssh", websocketPath: `/api/environment-custom-image-setup-sessions/${encodeURIComponent(minted.session.setupSessionId)}/terminal/ws` - + `?terminalSessionId=${encodeURIComponent(minted.session.id)}&token=${encodeURIComponent(minted.token)}`, + + `?terminalSessionId=${encodeURIComponent(minted.session.id)}`, }); }, ); diff --git a/server/src/services/environment-custom-image-terminal-sessions.test.ts b/server/src/services/environment-custom-image-terminal-sessions.test.ts index 2dcf305362d..a9a6616d180 100644 --- a/server/src/services/environment-custom-image-terminal-sessions.test.ts +++ b/server/src/services/environment-custom-image-terminal-sessions.test.ts @@ -126,6 +126,7 @@ describe("EnvironmentCustomImageTerminalSessionStore", () => { expect(minted.session.id).toMatch(/^[0-9a-f-]{36}$/); expect(minted.session.connectExpiresAt.toISOString()).toBe("2026-06-25T20:05:00.000Z"); expect(minted.session.sessionExpiresAt.toISOString()).toBe("2026-06-25T20:30:00.000Z"); + expect(minted.session.hostKeySha256).toBeNull(); expect(store.get({ id: minted.session.id, token: minted.token, @@ -171,6 +172,42 @@ describe("EnvironmentCustomImageTerminalSessionStore", () => { expect(store.getById(minted.session.id, new Date("2026-06-25T20:30:00.000Z"))).toBeNull(); }); + it("pins the first SSH host key fingerprint for a terminal session", () => { + const store = new EnvironmentCustomImageTerminalSessionStore(); + const now = new Date("2026-06-25T20:00:00.000Z"); + const minted = store.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "ssh-token-secret", host: "203.0.113.10", port: 2222 }, + setupExpiresAt: new Date("2026-06-25T20:30:00.000Z"), + now, + }); + + expect(store.verifyOrPinHostKey({ + id: minted.session.id, + hostKeySha256: "first-host-key-sha256", + }, now)).toBe(true); + expect(store.getById(minted.session.id, now)?.hostKeySha256).toBe("first-host-key-sha256"); + expect(store.verifyOrPinHostKey({ + id: minted.session.id, + hostKeySha256: "first-host-key-sha256", + }, now)).toBe(true); + expect(store.verifyOrPinHostKey({ + id: minted.session.id, + hostKeySha256: "changed-host-key-sha256", + }, now)).toBe(false); + expect(store.verifyOrPinHostKey({ + id: minted.session.id, + hostKeySha256: "", + }, now)).toBe(false); + expect(store.verifyOrPinHostKey({ + id: minted.session.id, + hostKeySha256: "first-host-key-sha256", + }, new Date("2026-06-25T20:30:00.000Z"))).toBe(false); + }); + it("deletes all tokens for a setup session", () => { const store = new EnvironmentCustomImageTerminalSessionStore(); const now = new Date("2026-06-25T20:00:00.000Z"); diff --git a/server/src/services/environment-custom-image-terminal-sessions.ts b/server/src/services/environment-custom-image-terminal-sessions.ts index 4c4e5008ffc..23d172bf0de 100644 --- a/server/src/services/environment-custom-image-terminal-sessions.ts +++ b/server/src/services/environment-custom-image-terminal-sessions.ts @@ -37,6 +37,7 @@ export interface EnvironmentCustomImageTerminalSessionRecord { provider: string; connectionType: "ssh"; ssh: ParsedCustomImageSetupSshCommand; + hostKeySha256: string | null; createdAt: Date; connectExpiresAt: Date; sessionExpiresAt: Date; @@ -177,6 +178,12 @@ function toValidFutureDate(value: Date | string | null | undefined, now: Date): return date.getTime() > now.getTime() ? date : null; } +function normalizeHostKeySha256(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized && normalized.length <= 256 ? normalized : null; +} + export class EnvironmentCustomImageTerminalSessionStore { private readonly sessionsById = new Map(); @@ -213,6 +220,7 @@ export class EnvironmentCustomImageTerminalSessionStore { provider: input.provider, connectionType: "ssh", ssh: input.ssh, + hostKeySha256: null, createdAt: now, connectExpiresAt, sessionExpiresAt: setupExpiresAt, @@ -247,6 +255,22 @@ export class EnvironmentCustomImageTerminalSessionStore { return stored.session; } + verifyOrPinHostKey(input: { id: string; hostKeySha256: string }, now = new Date()): boolean { + const hostKeySha256 = normalizeHostKeySha256(input.hostKeySha256); + if (!input.id || !hostKeySha256) return false; + const stored = this.sessionsById.get(input.id) ?? null; + if (!stored) return false; + if (stored.session.sessionExpiresAt.getTime() <= now.getTime()) { + this.sessionsById.delete(input.id); + return false; + } + if (!stored.session.hostKeySha256) { + stored.session.hostKeySha256 = hostKeySha256; + return true; + } + return stored.session.hostKeySha256 === hostKeySha256; + } + delete(id: string): boolean { if (!id) return false; return this.sessionsById.delete(id); diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index 66773dca8db..8f639e1f9fc 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -385,7 +385,7 @@ describe("CompanyEnvironments — test provider button", () => { environmentId: "env-1", connectionType: "ssh", websocketPath: - "/api/environment-custom-image-setup-sessions/session-1/terminal/ws?terminalSessionId=terminal-session-1&token=terminal-token-terminal-token-123456", + "/api/environment-custom-image-setup-sessions/session-1/terminal/ws?terminalSessionId=terminal-session-1", }); mockEnvironmentsApi.finishCustomImageSetupSession.mockResolvedValue({ session: createSession({ status: "promoted", promotedTemplateId: "template-1", finishedAt: "2026-06-25T20:10:00.000Z" }), @@ -783,6 +783,8 @@ describe("CompanyEnvironments — test provider button", () => { expect(FakeWebSocket.instances[0].url).toContain( "/api/environment-custom-image-setup-sessions/session-1/terminal/ws?terminalSessionId=terminal-session-1", ); + expect(FakeWebSocket.instances[0].url).not.toContain("token="); + expect(FakeWebSocket.instances[0].url).not.toContain("terminal-token-terminal-token-123456"); expect(FakeWebSocket.instances[0].url).toContain("cols=120"); expect(FakeWebSocket.instances[0].url).toContain("rows=32"); expect(xtermMocks.terminalInstances[0].options.cursorBlink).toBe(true); @@ -812,6 +814,10 @@ describe("CompanyEnvironments — test provider button", () => { expect(document.activeElement).toBe(terminalScreen); expect(getOpenDialog()?.textContent).toContain(command); }); + expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ + type: "auth", + token: "terminal-token-terminal-token-123456", + })); expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "resize", cols: 120, rows: 32 })); await act(async () => { diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index f32c4a2a6b5..ffb13d7760d 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -515,6 +515,7 @@ function EnvironmentCustomImageBrowserTerminal({ socket.onopen = () => { if (socketRef.current !== socket) return; xtermRef.current?.focus(); + socket.send(JSON.stringify({ type: "auth", token: terminalToken.token })); sendTerminalResize(true); };