Secure custom image terminal websocket auth

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-07-02 21:13:51 -07:00
1 parent f5b2564e33
commit e373622b12
8 files changed
+377 -152

No files matched your search

@@ -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,
@@ -142,12 +142,6 @@ function waitForOpen(ws: InstanceType<typeof WebSocket>) {
});
}
function waitForError(ws: InstanceType<typeof WebSocket>) {
return new Promise<Error>((resolve) => {
ws.on("error", resolve);
});
}
function waitForClose(ws: InstanceType<typeof WebSocket>) {
return new Promise<void>((resolve) => {
ws.on("close", resolve);
@@ -167,10 +161,14 @@ function waitForJsonMessage<T extends Record<string, unknown>>(
});
}
function terminalUrl(port: number, input: { setupSessionId?: string; terminalSessionId: string; token: string }) {
function sendTerminalAuth(ws: InstanceType<typeof WebSocket>, 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");
@@ -76,10 +76,18 @@ export interface EnvironmentCustomImageSshConnector {
term: string;
cols: number;
rows: number;
verifyHostKeySha256: (hostKeySha256: string) => boolean;
}): Promise<EnvironmentCustomImageSshShell>;
}
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<EnvironmentCustomImageTerminalPayloadValidationResult, { ok: false }>,
): Error {
@@ -212,6 +235,50 @@ function closeClient(socket: TerminalWsSocket, code: number, reason: string) {
socket.close(code, reason.slice(0, 120));
}
function readResizeDimensions(frame: Record<string, unknown>): { 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<string, unknown> | 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<string, unknown>
: 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<typeof setTimeout> | null = null;
let authTimer: ReturnType<typeof setTimeout> | 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;
+1 -1
View File
@@ -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)}`,
});
},
);
@@ -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");
@@ -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<string, StoredEnvironmentCustomImageTerminalSession>();
@@ -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);
+7 -1
View File
@@ -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 () => {
+1
View File
@@ -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);
};