mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Secure custom image terminal websocket auth
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user