mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
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:
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user