fix: harden connection, payload, and persisted state handling (#1354)

* fix: clean up Cloudflare tunnel timeouts

* fix: couple tunnel socket lifecycle

* fix: validate Docker console messages

* fix: bound homepage proxy responses

* fix: bound reconnect and response failures

* fix: harden persisted and socket state

* fix: support local connections to shared hosts
This commit is contained in:
ZacharyZcR
2026-08-28 10:36:39 +08:00
committed by GitHub
parent 703e8cd037
commit c129666d7f
28 changed files with 689 additions and 157 deletions
@@ -12,6 +12,7 @@ const faviconCache = new Map<
>();
const CACHE_SIZE = 100;
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
const MAX_FAVICON_BYTES = 512 * 1024;
function evictIfNeeded() {
if (faviconCache.size >= CACHE_SIZE) {
@@ -25,14 +26,31 @@ function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
const mod = url.startsWith("https") ? https : http;
const req = mod.get(url, { timeout: 5000 }, (res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
let bytes = 0;
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
res.destroy();
reject(error);
};
res.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > MAX_FAVICON_BYTES) {
fail(new Error("Favicon response too large"));
return;
}
chunks.push(chunk);
});
res.on("end", () => {
if (settled) return;
settled = true;
resolve({
data: Buffer.concat(chunks),
contentType: res.headers["content-type"] || "image/x-icon",
});
});
res.on("error", reject);
res.on("error", fail);
});
req.on("error", reject);
req.on("timeout", () => {
@@ -95,7 +113,7 @@ homepageFaviconRouter.get("/", async (req: Request, res: Response) => {
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
res.send(data);
} catch (err) {
} catch {
homepageLogger.warn("Failed to fetch favicon", { domain });
res.status(500).json({ error: "Failed to fetch favicon" });
}
@@ -1,11 +1,10 @@
import { getErrorMessage } from "../../utils/error-message.js";
import express, { type Request, type Response } from "express";
import https from "https";
import http from "http";
import { lookup } from "dns/promises";
import { isIP } from "net";
import { homepageLogger } from "../../utils/logger.js";
import { isBlockedAddress } from "../../utils/safe-outbound-fetch.js";
import {
readResponseTextLimited,
safeOutboundFetch,
} from "../../utils/safe-outbound-fetch.js";
export const homepageProxyRouter = express.Router();
@@ -17,68 +16,20 @@ interface ProxyCacheEntry {
const proxyCache = new Map<string, ProxyCacheEntry>();
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
async function resolvePublicUrl(rawUrl: string): Promise<{
url: URL;
address: string;
}> {
const url = new URL(rawUrl);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password
) {
throw new Error("Invalid URL");
}
const hostname = url.hostname.replace(/^\[|\]$/g, "");
const addresses = isIP(hostname)
? [{ address: hostname }]
: await lookup(hostname, { all: true, verbatim: true });
if (
addresses.length === 0 ||
addresses.some(({ address }) => isBlockedAddress(address))
) {
throw new Error("Private destinations are not allowed");
}
return { url, address: addresses[0].address };
}
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60;
async function fetchJson(rawUrl: string): Promise<unknown> {
const { url, address } = await resolvePublicUrl(rawUrl);
return new Promise((resolve, reject) => {
const mod = url.protocol === "https:" ? https : http;
const req = mod.get(
{
protocol: url.protocol,
hostname: address,
port: url.port || undefined,
path: `${url.pathname}${url.search}`,
headers: { Host: url.host },
servername: url.protocol === "https:" ? url.hostname : undefined,
timeout: FETCH_TIMEOUT_MS,
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
try {
const text = Buffer.concat(chunks).toString("utf-8");
resolve(JSON.parse(text));
} catch {
reject(new Error("Response is not valid JSON"));
}
});
res.on("error", reject);
},
);
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Fetch timeout"));
});
const response = await safeOutboundFetch(rawUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`Proxy fetch failed: ${response.status}`);
const text = await readResponseTextLimited(response, MAX_RESPONSE_BYTES);
try {
return JSON.parse(text);
} catch {
throw new Error("Response is not valid JSON");
}
}
/**
@@ -109,7 +60,9 @@ async function fetchJson(rawUrl: string): Promise<unknown> {
*/
homepageProxyRouter.get("/", async (req: Request, res: Response) => {
const targetUrl = req.query.url as string;
const ttl = Math.max(10, Number(req.query.ttl) || 60) * 1000;
const ttl =
Math.min(MAX_CACHE_TTL_SECONDS, Math.max(10, Number(req.query.ttl) || 60)) *
1000;
if (!targetUrl) return res.status(400).json({ error: "url is required" });
try {
@@ -1,6 +1,9 @@
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
import {
readResponseTextLimited,
safeOutboundFetch,
} from "../../utils/safe-outbound-fetch.js";
export const homepageRssRouter = express.Router();
@@ -8,6 +11,7 @@ const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
interface RssItem {
title: string;
@@ -21,7 +25,7 @@ function fetchXml(url: string): Promise<string> {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
}).then(async (res) => {
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`);
return res.text();
return readResponseTextLimited(res, MAX_RESPONSE_BYTES);
});
}
@@ -131,7 +135,7 @@ homepageRssRouter.get("/", async (req: Request, res: Response) => {
rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
res.json(items.slice(0, max));
} catch (err) {
} catch {
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
res.status(500).json({ error: "Failed to fetch feed" });
}
+72
View File
@@ -1812,6 +1812,78 @@ router.get(
},
);
/**
* Returns the minimum authentication material needed by the desktop app to
* connect to a shared host from the recipient's own network. The response is
* deliberately transient: callers must not persist it or include it in logs.
*/
router.get(
"/db/host/:id/local-connection-auth",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId) || !Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const access = await permissionManager.canAccessHost(
userId,
hostId,
"connect",
);
if (!access.hasAccess || !access.isShared) {
return res.status(404).json({ error: "Shared host not found" });
}
const repository = createCurrentHostResolutionRepository();
const ownerId = await repository.findHostOwnerId(hostId);
const host = ownerId
? await repository.findHostById(hostId, ownerId)
: null;
if (!host) {
return res.status(404).json({ error: "Shared host not found" });
}
const resolved = await resolveHostCredentials(
{
...transformHostResponse(host),
isShared: true,
permissionLevel: access.permissionLevel,
},
userId,
);
res.setHeader("Cache-Control", "no-store");
return res.json({
username: resolved.username,
authType: resolved.authType,
password: resolved.password || null,
key: resolved.key || null,
keyPassword: resolved.keyPassword || null,
keyType: resolved.keyType || null,
});
} catch (error) {
sshLogger.error(
"Failed to resolve shared host local authentication",
error,
{
operation: "shared_host_local_auth_resolve",
hostId,
userId,
},
);
return res
.status(500)
.json({ error: "Failed to resolve shared host authentication" });
}
},
);
/**
* @openapi
* /host/db/host/{id}/password:
+64
View File
@@ -0,0 +1,64 @@
import { Duplex } from "node:stream";
import type { RawData, WebSocket } from "ws";
export function waitForWebSocketOpen(
socket: WebSocket,
timeoutMs: number,
): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer);
socket.off("open", onOpen);
socket.off("error", onError);
};
const onOpen = () => {
cleanup();
resolve();
};
const onError = (error: Error) => {
cleanup();
socket.terminate();
reject(error);
};
const timer = setTimeout(() => {
cleanup();
socket.terminate();
reject(new Error("Cloudflare tunnel timeout"));
}, timeoutMs);
socket.once("open", onOpen);
socket.once("error", onError);
});
}
export function createWebSocketDuplex(socket: WebSocket): Duplex {
const duplex = new Duplex({
read() {},
write(chunk, _encoding, callback) {
try {
socket.send(chunk, (error) => callback(error || undefined));
} catch (error) {
callback(error instanceof Error ? error : new Error(String(error)));
}
},
destroy(error, callback) {
cleanup();
socket.terminate();
callback(error);
},
});
const onMessage = (data: RawData) => duplex.push(data);
const onClose = () => duplex.destroy();
const onError = () => duplex.destroy();
const cleanup = () => {
socket.off("message", onMessage);
socket.off("close", onClose);
socket.off("error", onError);
};
socket.on("message", onMessage);
socket.on("close", onClose);
socket.on("error", onError);
return duplex;
}
+49 -31
View File
@@ -20,6 +20,13 @@ import {
HOST_NOT_ON_THIS_SERVER_MESSAGE,
} from "../terminal/host-identity.js";
import { extractWebSocketToken } from "../../utils/ws-auth.js";
import {
asObject,
asString,
MAX_WS_MESSAGE_BYTES,
parseWsMessage,
toTerminalDimension,
} from "../../utils/ws-message.js";
const sshLogger = systemLogger;
@@ -38,6 +45,7 @@ const activeSessions = new Map<string, SSHSession>();
const wss = new WebSocketServer({
host: "127.0.0.1",
port: 30009,
maxPayload: MAX_WS_MESSAGE_BYTES,
});
wss.on("error", (error) => {
@@ -294,7 +302,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
const authManagerInstance = AuthManager.getInstance();
const payload = await authManagerInstance.verifyJWTToken(token);
let payload;
try {
payload = await authManagerInstance.verifyJWTToken(token);
} catch (error) {
sshLogger.warn("Docker console JWT verification failed", {
operation: "docker_console_auth_error",
error: getErrorMessage(error),
});
ws.close(1008, "Authentication required");
return;
}
if (!payload?.userId || payload.pendingTOTP) {
ws.close(1008, "Authentication required");
return;
@@ -316,20 +334,29 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
}, 30000);
const cleanup = () => {
clearInterval(wsPingInterval);
if (!sshSession) return;
sshSession.stream?.end();
sshSession.client.end();
activeSessions.delete(sessionId);
sshSession = null;
};
ws.on("message", async (data) => {
try {
const message = JSON.parse(data.toString());
const message = parseWsMessage(data);
switch (message.type) {
case "connect": {
const { hostConfig, containerId, shell, cols, rows } =
message.data as {
hostConfig: SSHHost;
containerId: string;
shell?: string;
cols?: number;
rows?: number;
};
const connectData = asObject(message.data);
const hostConfig = asObject(
connectData.hostConfig,
) as unknown as SSHHost;
const containerId = asString(connectData.containerId);
const shell = asString(connectData.shell) || undefined;
const cols = toTerminalDimension(connectData.cols) || 80;
const rows = toTerminalDimension(connectData.rows) || 24;
const hostId = hostConfig?.id;
@@ -595,8 +622,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
{
pty: {
term: "xterm-256color",
cols: cols || 80,
rows: rows || 24,
cols,
rows,
},
},
(err, stream) => {
@@ -694,7 +721,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshLogger.error("Failed to connect to container", error, {
operation: "console_connect",
sessionId,
containerId: message.data.containerId,
containerId,
});
ws.send(
@@ -712,15 +739,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
case "input": {
if (sshSession && sshSession.stream) {
sshSession.stream.write(message.data);
const input = asString(message.data);
if (input) sshSession.stream.write(input);
}
break;
}
case "resize": {
if (sshSession && sshSession.stream) {
const { cols, rows } = message.data;
sshSession.stream.setWindow(rows, cols, rows, cols);
const dimensions = asObject(message.data);
const cols = toTerminalDimension(dimensions.cols);
const rows = toTerminalDimension(dimensions.rows);
if (cols && rows)
sshSession.stream.setWindow(rows, cols, rows, cols);
}
break;
}
@@ -772,7 +803,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
});
ws.on("close", () => {
clearInterval(wsPingInterval);
sshLogger.info("Docker console disconnected", {
operation: "docker_console_disconnect",
sessionId,
@@ -780,13 +810,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
hostId: sshSession?.hostId,
containerId: sshSession?.containerId,
});
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
}
cleanup();
});
ws.on("error", (error) => {
@@ -795,13 +819,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sessionId,
});
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
}
cleanup();
});
});
+6 -17
View File
@@ -68,6 +68,10 @@ import {
resolveServerJumpHosts,
} from "./host-identity.js";
import { extractWebSocketToken } from "../../utils/ws-auth.js";
import {
createWebSocketDuplex,
waitForWebSocketOpen,
} from "../cloudflare-websocket.js";
interface ConnectToHostData {
cols: number;
@@ -3285,24 +3289,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
},
});
await new Promise<void>((resolve, reject) => {
cfWs.on("open", () => resolve());
cfWs.on("error", (err) => reject(err));
setTimeout(
() => reject(new Error("Cloudflare tunnel timeout")),
30000,
);
});
await waitForWebSocketOpen(cfWs, 30000);
const { Duplex } = await import("stream");
const duplexStream = new Duplex({
read() {},
write(chunk, _encoding, callback) {
cfWs.send(chunk, callback);
},
});
cfWs.on("message", (data) => duplexStream.push(data));
cfWs.on("close", () => duplexStream.push(null));
const duplexStream = createWebSocketDuplex(cfWs);
connectConfig.sock =
duplexStream as unknown as typeof connectConfig.sock;
@@ -0,0 +1,74 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
import {
createWebSocketDuplex,
waitForWebSocketOpen,
} from "../../hosts/cloudflare-websocket.js";
function setupSocket() {
const socket = new EventEmitter() as EventEmitter & {
terminate: ReturnType<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
};
socket.terminate = vi.fn();
socket.send = vi.fn();
return socket as unknown as WebSocket;
}
describe("waitForWebSocketOpen", () => {
afterEach(() => vi.useRealTimers());
it("clears listeners and leaves an opened socket alive", async () => {
vi.useFakeTimers();
const socket = setupSocket();
const result = waitForWebSocketOpen(socket, 30_000);
socket.emit("open");
await expect(result).resolves.toBeUndefined();
expect(socket.terminate).not.toHaveBeenCalled();
expect(socket.listenerCount("open")).toBe(0);
expect(socket.listenerCount("error")).toBe(0);
expect(vi.getTimerCount()).toBe(0);
});
it("terminates a socket that does not open before the deadline", async () => {
vi.useFakeTimers();
const socket = setupSocket();
const result = waitForWebSocketOpen(socket, 30_000);
const rejection = expect(result).rejects.toThrow(
"Cloudflare tunnel timeout",
);
await vi.advanceTimersByTimeAsync(30_000);
await rejection;
expect(socket.terminate).toHaveBeenCalledOnce();
expect(socket.listenerCount("open")).toBe(0);
expect(socket.listenerCount("error")).toBe(0);
});
it("terminates the socket when the SSH duplex is destroyed", () => {
const socket = setupSocket();
const duplex = createWebSocketDuplex(socket);
duplex.destroy();
expect(socket.terminate).toHaveBeenCalledOnce();
expect(socket.listenerCount("message")).toBe(0);
expect(socket.listenerCount("close")).toBe(0);
expect(socket.listenerCount("error")).toBe(0);
});
it("handles established socket errors without an unhandled event", () => {
const socket = setupSocket();
const duplex = createWebSocketDuplex(socket);
socket.emit("error", new Error("connection lost"));
expect(duplex.destroyed).toBe(true);
expect(socket.terminate).toHaveBeenCalledOnce();
expect(socket.listenerCount("error")).toBe(0);
});
});
@@ -3,6 +3,7 @@ import type { LookupAddress, LookupOptions } from "dns";
import {
createDnsLookupHook,
isBlockedAddress,
readResponseTextLimited,
} from "../../utils/safe-outbound-fetch.js";
describe("isBlockedAddress", () => {
@@ -47,6 +48,37 @@ describe("isBlockedAddress", () => {
});
});
describe("readResponseTextLimited", () => {
it("reads a response within the configured limit", async () => {
const response = new Response("hello");
await expect(readResponseTextLimited(response, 5)).resolves.toBe("hello");
});
it("rejects a declared oversized response before buffering it", async () => {
const response = new Response("small", {
headers: { "content-length": "100" },
});
await expect(readResponseTextLimited(response, 10)).rejects.toThrow(
"Response exceeds 10 bytes",
);
});
it("stops a chunked response once its actual body exceeds the limit", async () => {
const response = new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("12345"));
controller.enqueue(new TextEncoder().encode("6"));
controller.close();
},
}),
);
await expect(readResponseTextLimited(response, 5)).rejects.toThrow(
"Response exceeds 5 bytes",
);
});
});
function runHook(
addresses: LookupAddress[] | string | undefined,
error: NodeJS.ErrnoException | null = null,
+29
View File
@@ -157,6 +157,35 @@ export interface OutboundTlsOptions {
rejectUnauthorized?: boolean;
}
export async function readResponseTextLimited(
response: Response,
maxBytes: number,
): Promise<string> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await response.body?.cancel().catch(() => {});
throw new Error(`Response exceeds ${maxBytes} bytes`);
}
if (!response.body) return "";
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => {});
throw new Error(`Response exceeds ${maxBytes} bytes`);
}
chunks.push(value);
}
return Buffer.concat(chunks, total).toString("utf8");
}
export async function safeOutboundFetch(
rawUrl: string,
options: RequestInit,
+2 -2
View File
@@ -3,7 +3,7 @@ import type { RawData } from "ws";
// Cap on a single decoded text frame. Anything larger is almost certainly
// abuse - the legitimate control messages here are tiny, and terminal input is
// bounded by what a user can type or paste.
const MAX_MESSAGE_BYTES = 1024 * 1024;
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
export class WsMessageError extends Error {}
@@ -27,7 +27,7 @@ export function parseWsMessage(raw: RawData): {
type: string;
data: unknown;
} {
if (rawByteLength(raw) > MAX_MESSAGE_BYTES) {
if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
throw new WsMessageError("Message too large");
}