file browser improvements and hardening.

This commit is contained in:
KingKDot
2026-07-14 09:50:09 -05:00
parent 172703c33b
commit 9178bd0e63
17 changed files with 700 additions and 105 deletions
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import {
FILE_BROWSER_MAX_ICON_ITEMS,
validateFileBrowserCommandPayload,
} from "./file-browser-security";
describe("file browser command payload validation", () => {
test("caps read and peek sizes", () => {
expect(validateFileBrowserCommandPayload("file_read", { path: "C:\\ok.txt", maxSize: 99_999_999 })?.maxSize)
.toBe(10 * 1024 * 1024);
expect(validateFileBrowserCommandPayload("file_peek", { path: "/tmp/ok", bytes: 99_999 })?.bytes)
.toBe(4096);
});
test("rejects control characters and oversized paths", () => {
expect(validateFileBrowserCommandPayload("file_read", { path: "C:\\bad\0.txt" })).toBeNull();
expect(validateFileBrowserCommandPayload("file_read", { path: "x".repeat(4097) })).toBeNull();
});
test("bounds icon and thumbnail batches", () => {
const icons = Array.from({ length: FILE_BROWSER_MAX_ICON_ITEMS + 1 }, (_, i) => ({ key: `ext:${i}`, ext: "txt" }));
expect(validateFileBrowserCommandPayload("file_icon", { items: icons })).toBeNull();
expect(validateFileBrowserCommandPayload("file_thumb", {
items: [{ key: "thumb:ok", path: "C:\\ok.png", size: 96 }],
})).not.toBeNull();
});
test("only permits agent upload pulls served by this application", () => {
expect(validateFileBrowserCommandPayload("file_upload_http", {
path: "C:\\ok.bin",
url: "/api/file/upload/pull/123e4567-e89b-42d3-a456-426614174000",
total: 10,
})).not.toBeNull();
expect(validateFileBrowserCommandPayload("file_upload_http", {
path: "C:\\ok.bin", url: "https://attacker.invalid/payload", total: 10,
})).toBeNull();
});
});
@@ -0,0 +1,119 @@
export const FILE_BROWSER_MAX_PATH = 4096;
export const FILE_BROWSER_MAX_READ_BYTES = 10 * 1024 * 1024;
export const FILE_BROWSER_MAX_WRITE_CHARS = 10 * 1024 * 1024;
export const FILE_BROWSER_MAX_ICON_ITEMS = 32;
export const FILE_BROWSER_MAX_THUMB_ITEMS = 8;
const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
const HASH_ALGORITHMS = new Set(["md5", "sha1", "sha256", "crc32"]);
function safeString(value: unknown, maxLength: number, allowEmpty = false): value is string {
return typeof value === "string"
&& value.length <= maxLength
&& (allowEmpty || value.length > 0)
&& !CONTROL_CHARS.test(value);
}
export function isSafeFileBrowserPath(value: unknown, allowEmpty = false): value is string {
return safeString(value, FILE_BROWSER_MAX_PATH, allowEmpty);
}
function safeCommandId(value: unknown): value is string {
return safeString(value, 128);
}
function safeItems(value: unknown, limit: number, thumbnail: boolean): boolean {
if (!Array.isArray(value) || value.length === 0 || value.length > limit) return false;
return value.every((item) => {
if (!item || typeof item !== "object") return false;
const entry = item as Record<string, unknown>;
if (!safeString(entry.key, FILE_BROWSER_MAX_PATH + 128)) return false;
if (entry.path !== undefined && !isSafeFileBrowserPath(entry.path)) return false;
if (entry.ext !== undefined && !safeString(entry.ext, 32, true)) return false;
if (thumbnail && (!Number.isInteger(entry.size) || Number(entry.size) < 16 || Number(entry.size) > 512)) return false;
return true;
});
}
export function validateFileBrowserCommandPayload(
commandType: string,
value: unknown,
): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const payload = value as Record<string, unknown>;
const pathOk = () => isSafeFileBrowserPath(payload.path);
switch (commandType) {
case "file_read":
if (!pathOk()) return null;
return { ...payload, maxSize: Math.min(FILE_BROWSER_MAX_READ_BYTES, Math.max(1, Number(payload.maxSize) || FILE_BROWSER_MAX_READ_BYTES)) };
case "file_write":
if (!pathOk() || typeof payload.content !== "string" || payload.content.length > FILE_BROWSER_MAX_WRITE_CHARS) return null;
return payload;
case "file_request_access":
case "file_execute":
case "file_dirsize":
return pathOk() ? payload : null;
case "file_search": {
if (!isSafeFileBrowserPath(payload.path, true) || !safeString(payload.pattern, 512)) return null;
if (payload.searchId !== undefined && !safeCommandId(payload.searchId)) return null;
return { ...payload, maxResults: Math.min(500, Math.max(1, Number(payload.maxResults) || 100)) };
}
case "file_copy":
case "file_move":
return isSafeFileBrowserPath(payload.source) && isSafeFileBrowserPath(payload.dest) ? payload : null;
case "file_chmod":
return pathOk() && safeString(payload.mode, 16) ? payload : null;
case "file_icon":
return safeItems(payload.items, FILE_BROWSER_MAX_ICON_ITEMS, false) ? payload : null;
case "file_thumb":
return safeItems(payload.items, FILE_BROWSER_MAX_THUMB_ITEMS, true) ? payload : null;
case "file_peek":
if (!pathOk()) return null;
return { ...payload, bytes: Math.min(4096, Math.max(1, Number(payload.bytes) || 4096)) };
case "file_hash": {
const algorithm = String(payload.algorithm || "sha256").toLowerCase();
return pathOk() && HASH_ALGORITHMS.has(algorithm) ? { ...payload, algorithm } : null;
}
case "file_upload_http":
if (!pathOk() || typeof payload.url !== "string"
|| !/^\/api\/file\/upload\/pull\/[0-9a-f-]{36}$/i.test(payload.url)) return null;
if (!Number.isSafeInteger(Number(payload.total)) || Number(payload.total) < 0) return null;
return payload;
default:
return null;
}
}
type RateState = {
windowStart: number;
commands: number;
expensiveWindowStart: number;
expensiveCommands: number;
};
const rateStates = new WeakMap<object, RateState>();
const RATE_WINDOW_MS = 10_000;
const RATE_MAX_COMMANDS = 120;
const EXPENSIVE_WINDOW_MS = 60_000;
const RATE_MAX_EXPENSIVE = 20;
export function consumeFileBrowserCommandRateLimit(socket: object, expensive: boolean): boolean {
const now = Date.now();
let state = rateStates.get(socket);
if (!state) {
state = { windowStart: now, commands: 0, expensiveWindowStart: now, expensiveCommands: 0 };
rateStates.set(socket, state);
}
if (now - state.windowStart >= RATE_WINDOW_MS) {
state.windowStart = now;
state.commands = 0;
}
if (now - state.expensiveWindowStart >= EXPENSIVE_WINDOW_MS) {
state.expensiveWindowStart = now;
state.expensiveCommands = 0;
}
state.commands++;
if (expensive) state.expensiveCommands++;
return state.commands <= RATE_MAX_COMMANDS && state.expensiveCommands <= RATE_MAX_EXPENSIVE;
}
@@ -26,7 +26,10 @@ export type PendingHttpDownload = {
nextExpectedOffset?: number;
streamErrored?: boolean;
onFirstChunk?: () => void;
onFirstChunkError?: (error: Error) => void;
firstChunkSignaled?: boolean;
userId?: number;
maxBytes?: number;
};
export type DownloadIntent = {
@@ -36,6 +39,7 @@ export type DownloadIntent = {
path: string;
expiresAt: number;
timeout: ReturnType<typeof setTimeout>;
maxBytes?: number;
};
export type UploadIntent = {
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test";
import {
consumeHttpDownloadPayload,
STREAM_MAX_CHUNK_BYTES,
type PendingHttpDownload,
} from "./http-download-consumer";
function pendingDownload(overrides: Partial<PendingHttpDownload> = {}) {
let resolved = false;
let streamError = "";
const pending: PendingHttpDownload = {
commandId: "cmd",
clientId: "client",
path: "/tmp/file",
fileName: "file",
total: 0,
receivedBytes: 0,
receivedOffsets: new Set(),
receivedChunks: new Set(),
chunkSize: 0,
expectedChunks: 0,
tmpPath: "",
fileHandle: null as any,
resolve: () => { resolved = true; },
reject: () => {},
timeout: setTimeout(() => {}, 60_000),
streamController: {
enqueue() {},
close() {},
error(error: Error) { streamError = error.message; },
} as any,
...overrides,
};
return { pending, state: () => ({ resolved, streamError }) };
}
describe("HTTP download stream limits", () => {
test("rejects chunks larger than the agent protocol maximum", async () => {
const fixture = pendingDownload();
const entries = new Map([[fixture.pending.commandId, fixture.pending]]);
await consumeHttpDownloadPayload({
commandId: "cmd", offset: 0, total: STREAM_MAX_CHUNK_BYTES + 1,
data: new Uint8Array(STREAM_MAX_CHUNK_BYTES + 1),
}, entries);
clearTimeout(fixture.pending.timeout);
expect(entries.has("cmd")).toBeFalse();
expect(fixture.state().streamError).toContain("chunk exceeded");
});
test("enforces preview byte budgets from total metadata", async () => {
const fixture = pendingDownload({ maxBytes: 1024 });
const entries = new Map([[fixture.pending.commandId, fixture.pending]]);
await consumeHttpDownloadPayload({ commandId: "cmd", total: 2048 }, entries);
clearTimeout(fixture.pending.timeout);
expect(entries.has("cmd")).toBeFalse();
expect(fixture.state().streamError).toContain("requested byte limit");
});
});
@@ -3,6 +3,8 @@ import type { FileHandle } from "fs/promises";
import { logger } from "../logger";
export const STREAM_REORDER_BUFFER_LIMIT = 16;
export const STREAM_REORDER_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
export const STREAM_MAX_CHUNK_BYTES = 4 * 1024 * 1024;
export type PendingHttpDownload = {
commandId: string;
@@ -27,7 +29,10 @@ export type PendingHttpDownload = {
nextExpectedOffset?: number;
streamErrored?: boolean;
onFirstChunk?: () => void;
onFirstChunkError?: (error: Error) => void;
firstChunkSignaled?: boolean;
userId?: number;
maxBytes?: number;
};
function isStreamMode(pending: PendingHttpDownload): boolean {
@@ -41,6 +46,10 @@ async function failStream(pending: PendingHttpDownload, err: Error): Promise<voi
pending.streamController?.error(err);
} catch {}
pending.reorderBuffer?.clear();
if (!pending.firstChunkSignaled && pending.onFirstChunkError) {
pending.firstChunkSignaled = true;
try { pending.onFirstChunkError(err); } catch {}
}
}
function enqueueOrdered(pending: PendingHttpDownload, offset: number, data: Uint8Array): boolean {
@@ -77,6 +86,12 @@ function enqueueOrdered(pending: PendingHttpDownload, offset: number, data: Uint
void failStream(pending, new Error("download chunks arrived too far out of order"));
return false;
}
let bufferedBytes = 0;
for (const chunk of pending.reorderBuffer.values()) bufferedBytes += chunk.byteLength;
if (bufferedBytes > STREAM_REORDER_BUFFER_MAX_BYTES) {
void failStream(pending, new Error("download reorder buffer byte limit exceeded"));
return false;
}
return true;
}
@@ -134,6 +149,13 @@ export async function consumeHttpDownloadPayload(
pending.total = total;
}
}
if (pending.maxBytes && pending.total > pending.maxBytes) {
await failStream(pending, new Error("download exceeded requested byte limit"));
clearTimeout(pending.timeout);
pendingHttpDownloads.delete(commandId);
pending.resolve(pending);
return;
}
if (pending.total > 0 && !pending.loggedTotal) {
pending.loggedTotal = true;
logger.debug("[filebrowser] http download total", {
@@ -159,6 +181,21 @@ export async function consumeHttpDownloadPayload(
const chunkIndex = toNumber(payload?.chunkIndex);
const chunksTotal = toNumber(payload?.chunksTotal);
if (data.byteLength > STREAM_MAX_CHUNK_BYTES) {
await failStream(pending, new Error("download chunk exceeded size limit"));
clearTimeout(pending.timeout);
pendingHttpDownloads.delete(commandId);
pending.resolve(pending);
return;
}
if (pending.maxBytes && pending.receivedBytes + data.byteLength > pending.maxBytes) {
await failStream(pending, new Error("download exceeded requested byte limit"));
clearTimeout(pending.timeout);
pendingHttpDownloads.delete(commandId);
pending.resolve(pending);
return;
}
if (offset === null) {
logger.debug("[filebrowser] http download missing offset", {
commandId,
@@ -191,6 +228,15 @@ export async function consumeHttpDownloadPayload(
}
const effectiveOffset = offset ?? 0;
if (effectiveOffset < 0
|| !Number.isSafeInteger(effectiveOffset)
|| (pending.total > 0 && effectiveOffset + data.byteLength > pending.total)) {
await failStream(pending, new Error("invalid download chunk offset"));
clearTimeout(pending.timeout);
pendingHttpDownloads.delete(commandId);
pending.resolve(pending);
return;
}
const shouldWrite = chunkIndex !== null
? !pending.receivedChunks.has(chunkIndex)
: !pending.receivedOffsets.has(effectiveOffset);
@@ -87,6 +87,13 @@ async function serveDownloadById(
const clientId = intent.clientId;
const downloadPath = intent.path;
const maxBytes = intent.maxBytes;
const activeForUser = [...deps.pendingHttpDownloads.values()]
.filter((pending) => pending.userId === user.userId).length;
if (activeForUser >= 8) {
return new Response("Too many concurrent downloads", { status: 429, headers: { "Retry-After": "10" } });
}
const target = clientManager.getClient(clientId);
if (!target) {
@@ -172,6 +179,9 @@ async function serveDownloadById(
reorderBuffer: new Map(),
nextExpectedOffset: 0,
onFirstChunk: () => firstChunkResolve(),
onFirstChunkError: (error) => firstChunkReject(error),
userId: user.userId,
maxBytes,
});
});
@@ -202,7 +212,7 @@ async function serveDownloadById(
type: "command",
commandType: "file_download",
id: commandId,
payload: { path: downloadPath },
payload: { path: downloadPath, ...(maxBytes ? { maxBytes } : {}) },
}),
);
@@ -704,7 +714,15 @@ export async function handleFileDownloadRoutes(
return new Response("Client offline", { status: 404 });
}
const activeIntentsForUser = [...deps.downloadIntents.values()]
.filter((intent) => intent.userId === user.userId && intent.expiresAt > Date.now()).length;
if (activeIntentsForUser >= 16) {
return new Response("Too many pending downloads", { status: 429, headers: { "Retry-After": "10" } });
}
const downloadId = uuidv4();
const requestedPreview = body?.preview === true;
const maxBytes = requestedPreview ? 50 * 1024 * 1024 : undefined;
const expiresAt = Date.now() + 2 * 60_000;
const timeout = setTimeout(() => {
deps.downloadIntents.delete(downloadId);
@@ -717,6 +735,7 @@ export async function handleFileDownloadRoutes(
path: downloadPath,
expiresAt,
timeout,
maxBytes,
});
return Response.json({
@@ -1,4 +1,5 @@
import { authenticateRequest } from "../../auth";
import { authenticateRequest, extractTokenFromRequest } from "../../auth";
import { hashTokenForSession } from "../../db";
import { logger } from "../../logger";
import { isIpBanned } from "../../db";
import type { FeatureName, UserRole } from "../../users";
@@ -198,6 +199,8 @@ async function tryClientViewerUpgrade(
userId: user.userId,
username: user.username,
};
const token = extractTokenFromRequest(req);
if (token) data.authTokenHash = hashTokenForSession(token);
if (route.sessionId) {
data.sessionId = route.sessionId();
}
@@ -5,11 +5,30 @@ import * as clientManager from "../clientManager";
import { logger } from "../logger";
import { metrics } from "../metrics";
import { encodeMessage } from "../protocol";
import { getSessionByTokenHash } from "../db";
import * as sessionManager from "../sessions/sessionManager";
import type { SocketData } from "../sessions/types";
import { normalizeFileUploadPayload } from "../fileTransfers";
import { canUserAccessClient } from "../users";
import { canUserAccessClient, canUserAccessFeature, getUserById } from "../users";
import { hasPermission } from "../rbac";
import { decodeViewerPayload, safeSendViewer } from "./ws-viewer-utils";
import {
consumeFileBrowserCommandRateLimit,
FILE_BROWSER_MAX_ICON_ITEMS,
FILE_BROWSER_MAX_READ_BYTES,
FILE_BROWSER_MAX_THUMB_ITEMS,
isSafeFileBrowserPath,
validateFileBrowserCommandPayload,
} from "./file-browser-security";
function boundedBytes(value: unknown, maxBytes: number): Uint8Array | null {
let bytes: Uint8Array;
if (value instanceof Uint8Array) bytes = value;
else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
else if (ArrayBuffer.isView(value)) bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
else return null;
return bytes.byteLength <= maxBytes ? bytes : null;
}
type FileBrowserViewer = {
id: string;
@@ -32,11 +51,55 @@ type WsViewerClusterDeps = {
const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024;
const fileBrowserCommandSessions = new Map<string, string>();
const fileBrowserCommandSessions = new Map<string, {
sessionId: string;
timeout: ReturnType<typeof setTimeout>;
}>();
function trackFileBrowserCommand(commandId: string, sessionId: string): void {
fileBrowserCommandSessions.set(commandId, sessionId);
setTimeout(() => fileBrowserCommandSessions.delete(commandId), 10 * 60 * 1000);
const existing = fileBrowserCommandSessions.get(commandId);
if (existing) clearTimeout(existing.timeout);
const timeout = setTimeout(() => fileBrowserCommandSessions.delete(commandId), 10 * 60 * 1000);
fileBrowserCommandSessions.set(commandId, { sessionId, timeout });
}
function finishFileBrowserCommand(commandId: string): void {
const entry = fileBrowserCommandSessions.get(commandId);
if (!entry) return;
clearTimeout(entry.timeout);
fileBrowserCommandSessions.delete(commandId);
}
function viewerCommandId(value: unknown, fallback: string): string {
return typeof value === "string" && value.length > 0 && value.length <= 128 ? value : fallback;
}
function denyFileBrowserViewer(ws: ServerWebSocket<SocketData>, reason: string): false {
logger.warn(`[filebrowser] closing unauthorized viewer: ${reason}`);
try { ws.close(1008, reason); } catch {}
return false;
}
function hasLiveFileBrowserAccess(ws: ServerWebSocket<SocketData>): boolean {
const { authTokenHash, clientId, userId } = ws.data;
if (!authTokenHash || userId === undefined) return denyFileBrowserViewer(ws, "Authentication expired");
const session = getSessionByTokenHash(authTokenHash);
if (!session || session.userId !== userId || session.expiresAt <= Math.floor(Date.now() / 1000)) {
return denyFileBrowserViewer(ws, "Session expired or revoked");
}
const user = getUserById(userId);
if (!user || user.role === "viewer") return denyFileBrowserViewer(ws, "File browser access denied");
if (!hasPermission(user.role, "clients:control", user.id)
|| !canUserAccessFeature(user.id, user.role, "file_browser")
|| !canUserAccessClient(user.id, user.role, clientId)) {
return denyFileBrowserViewer(ws, "File browser access revoked");
}
ws.data.userRole = user.role;
return true;
}
function rejectFileBrowserCommand(ws: ServerWebSocket<SocketData>, message: string): void {
safeSendViewer(ws, { type: "command_error", error: message });
}
export function handleFileBrowserViewerOpen(ws: ServerWebSocket<SocketData>) {
@@ -58,7 +121,16 @@ export function handleFileBrowserViewerOpen(ws: ServerWebSocket<SocketData>) {
export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>, raw: string | ArrayBuffer | Uint8Array) {
const payload = decodeViewerPayload(raw);
const expensive = payload?.commandType === "file_hash"
|| payload?.commandType === "file_thumb"
|| payload?.type === "file_download"
|| payload?.type === "file_zip";
if (!consumeFileBrowserCommandRateLimit(ws, expensive)) {
rejectFileBrowserCommand(ws, "File browser command rate limit exceeded");
return;
}
if (!payload || typeof payload.type !== "string") return;
if (!hasLiveFileBrowserAccess(ws)) return;
const { clientId } = ws.data;
logger.debug(`[DEBUG] File browser message from viewer for client ${clientId}:`, payload.type, payload.commandType || "");
@@ -74,8 +146,28 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
if (payload.type === "command") {
if (typeof payload.commandType !== "string") return;
logger.debug(`[DEBUG] Handling command type: ${payload.commandType}`);
const actualPayload = payload.payload || {};
const routedId = payload.id || commandId;
if (payload.commandType === "silent_exec") {
const current = getUserById(ws.data.userId!);
if (!current || !hasPermission(current.role, "clients:silent-exec", current.id)) {
rejectFileBrowserCommand(ws, "Silent execution is not permitted");
return;
}
}
if (payload.commandType === "silent_exec") {
const command = payload.payload?.command;
if (typeof command !== "string" || command.length === 0 || command.length > 32_768 || /[\x00]/.test(command)) {
rejectFileBrowserCommand(ws, "Invalid silent execution payload");
return;
}
}
const actualPayload = payload.commandType === "silent_exec"
? payload.payload
: validateFileBrowserCommandPayload(payload.commandType, payload.payload || {});
if (!actualPayload) {
rejectFileBrowserCommand(ws, "Invalid file browser command payload");
return;
}
const routedId = typeof payload.id === "string" && payload.id.length <= 128 ? payload.id : commandId;
if (ws.data.sessionId) trackFileBrowserCommand(routedId, ws.data.sessionId);
switch (payload.commandType) {
case "file_read":
@@ -167,6 +259,7 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
switch (payload.type) {
case "file_list":
if (!isSafeFileBrowserPath(payload.path, true)) return rejectFileBrowserCommand(ws, "Invalid path");
if (ws.data.sessionId) trackFileBrowserCommand(commandId, ws.data.sessionId);
target.ws.send(encodeMessage({ type: "command", commandType: "file_list", id: commandId, payload: { path: payload.path || "" } } as any));
metrics.recordCommand("file_list");
@@ -181,6 +274,7 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
});
break;
case "file_download":
if (!isSafeFileBrowserPath(payload.path)) return rejectFileBrowserCommand(ws, "Invalid path");
if (ws.data.sessionId) trackFileBrowserCommand(commandId, ws.data.sessionId);
target.ws.send(encodeMessage({ type: "command", commandType: "file_download", id: commandId, payload: { path: payload.path || "" } } as any));
metrics.recordCommand("file_download");
@@ -197,6 +291,10 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
case "file_upload": {
const upload = normalizeFileUploadPayload(payload);
if (!upload) return;
if (!isSafeFileBrowserPath(upload.path)) {
rejectFileBrowserCommand(ws, "Invalid upload path");
return;
}
if (upload.total > WS_UPLOAD_MAX_TOTAL) {
safeSendViewer(ws, {
type: "file_upload_result",
@@ -208,7 +306,7 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
});
break;
}
const uploadCommandId = payload.commandId || commandId;
const uploadCommandId = viewerCommandId(payload.commandId, commandId);
if (ws.data.sessionId) trackFileBrowserCommand(uploadCommandId, ws.data.sessionId);
target.ws.send(encodeMessage({
type: "command",
@@ -237,7 +335,8 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
break;
}
case "file_delete": {
const deleteCommandId = payload.commandId || commandId;
if (!isSafeFileBrowserPath(payload.path)) return rejectFileBrowserCommand(ws, "Invalid path");
const deleteCommandId = viewerCommandId(payload.commandId, commandId);
if (ws.data.sessionId) trackFileBrowserCommand(deleteCommandId, ws.data.sessionId);
target.ws.send(encodeMessage({ type: "command", commandType: "file_delete", id: deleteCommandId, payload: { path: payload.path || "" } } as any));
metrics.recordCommand("file_delete");
@@ -253,7 +352,8 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
break;
}
case "file_mkdir": {
const mkdirCommandId = payload.commandId || commandId;
if (!isSafeFileBrowserPath(payload.path)) return rejectFileBrowserCommand(ws, "Invalid path");
const mkdirCommandId = viewerCommandId(payload.commandId, commandId);
if (ws.data.sessionId) trackFileBrowserCommand(mkdirCommandId, ws.data.sessionId);
target.ws.send(encodeMessage({ type: "command", commandType: "file_mkdir", id: mkdirCommandId, payload: { path: payload.path || "" } } as any));
metrics.recordCommand("file_mkdir");
@@ -269,7 +369,8 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
break;
}
case "file_zip": {
const zipCommandId = payload.commandId || commandId;
if (!isSafeFileBrowserPath(payload.path)) return rejectFileBrowserCommand(ws, "Invalid path");
const zipCommandId = viewerCommandId(payload.commandId, commandId);
if (ws.data.sessionId) trackFileBrowserCommand(zipCommandId, ws.data.sessionId);
target.ws.send(encodeMessage({ type: "command", commandType: "file_zip", id: zipCommandId, payload: { path: payload.path || "" } } as any));
metrics.recordCommand("file_zip");
@@ -285,7 +386,9 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
break;
}
case "command_abort":
target.ws.send(encodeMessage({ type: "command_abort", commandId: payload.commandId } as any));
if (typeof payload.commandId === "string" && payload.commandId.length <= 128) {
target.ws.send(encodeMessage({ type: "command_abort", commandId: payload.commandId } as any));
}
break;
default:
break;
@@ -304,7 +407,8 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W
}
const payloadCommandId = typeof payload?.commandId === "string" ? payload.commandId : undefined;
const ownerSessionId = payloadCommandId ? fileBrowserCommandSessions.get(payloadCommandId) : undefined;
const commandOwner = payloadCommandId ? fileBrowserCommandSessions.get(payloadCommandId) : undefined;
const ownerSessionId = commandOwner?.sessionId;
let hasSession = false;
for (const session of sessionManager.getFileBrowserSessionsByClient(clientId)) {
@@ -321,30 +425,65 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W
continue;
}
if (payload.type === "file_download" && payload.data) {
const data = payload.data instanceof Uint8Array ? payload.data : new Uint8Array(payload.data);
safeSendViewer(session.viewer, { ...payload, data });
const data = boundedBytes(payload.data, 4 * 1024 * 1024);
safeSendViewer(session.viewer, data
? { ...payload, data }
: { type: "file_download", commandId: payload.commandId, path: payload.path, error: "Download chunk exceeded limit" });
} else if (payload.type === "file_icon_result" && Array.isArray(payload.icons)) {
const icons = payload.icons.map((item: any) => {
if (item && item.png && !(item.png instanceof Uint8Array)) {
return { ...item, png: new Uint8Array(item.png) };
}
return item;
const icons = payload.icons.slice(0, FILE_BROWSER_MAX_ICON_ITEMS).flatMap((item: any) => {
if (!item || typeof item.key !== "string" || item.key.length > 4224) return [];
const png = item.png ? boundedBytes(item.png, 512 * 1024) : null;
return [{ key: item.key, ...(png ? { png } : {}), ...(item.error ? { error: String(item.error).slice(0, 512) } : {}) }];
});
safeSendViewer(session.viewer, { ...payload, icons });
} else if (payload.type === "file_thumb_result" && Array.isArray(payload.thumbs)) {
const thumbs = payload.thumbs.map((item: any) => {
if (item && item.jpeg && !(item.jpeg instanceof Uint8Array)) {
return { ...item, jpeg: new Uint8Array(item.jpeg) };
}
return item;
const thumbs = payload.thumbs.slice(0, FILE_BROWSER_MAX_THUMB_ITEMS).flatMap((item: any) => {
if (!item || typeof item.key !== "string" || item.key.length > 4224) return [];
const jpeg = item.jpeg ? boundedBytes(item.jpeg, 1024 * 1024) : null;
return [{
key: item.key,
...(jpeg ? { jpeg } : {}),
w: Math.min(512, Math.max(0, Number(item.w) || 0)),
h: Math.min(512, Math.max(0, Number(item.h) || 0)),
...(item.error ? { error: String(item.error).slice(0, 512) } : {}),
}];
});
safeSendViewer(session.viewer, { ...payload, thumbs });
} else if (payload.type === "file_peek_result" && payload.data && !(payload.data instanceof Uint8Array)) {
safeSendViewer(session.viewer, { ...payload, data: new Uint8Array(payload.data) });
} else if (payload.type === "file_peek_result" && payload.data) {
const data = boundedBytes(payload.data, 4096);
safeSendViewer(session.viewer, data
? { ...payload, data }
: { type: "file_peek_result", commandId: payload.commandId, path: payload.path, error: "Preview data exceeded limit" });
} else if (payload.type === "file_read_result" && typeof payload.content === "string") {
safeSendViewer(session.viewer, payload.content.length <= FILE_BROWSER_MAX_READ_BYTES
? payload
: { type: "file_read_result", commandId: payload.commandId, path: payload.path, error: "File content exceeded editor limit" });
} else if (payload.type === "file_search_result" && Array.isArray(payload.results)) {
const results = payload.results.slice(0, 500).flatMap((result: any) => {
if (!result || !isSafeFileBrowserPath(result.path)) return [];
const line = Number(result.line);
return [{
path: result.path,
...(Number.isSafeInteger(line) && line > 0 ? { line } : {}),
...(typeof result.match === "string" ? { match: result.match.slice(0, 4096) } : {}),
}];
});
safeSendViewer(session.viewer, { ...payload, results });
} else {
safeSendViewer(session.viewer, payload);
}
}
if (payloadCommandId) {
const isTerminalDownload = type === "file_download" && (
!!payload?.error
|| (Number.isFinite(Number(payload?.chunksTotal))
&& Number.isFinite(Number(payload?.chunkIndex))
&& Number(payload.chunkIndex) + 1 >= Number(payload.chunksTotal))
);
if (type === "command_result" || type?.endsWith("_result") || isTerminalDownload) {
finishFileBrowserCommand(payloadCommandId);
}
}
}
export function handleProcessViewerOpen(ws: ServerWebSocket<SocketData>) {
+1
View File
@@ -24,6 +24,7 @@ export type SocketData = {
userRole?: string;
userId?: number;
username?: string;
authTokenHash?: string;
wasKnown?: boolean;
autoTasksRan?: boolean;
autoDeploysRan?: boolean;