allow easy custom branding in settings + fix some auth shit

This commit is contained in:
KingKDot
2026-07-17 20:09:47 -05:00
parent f404f22b29
commit 1cdeae0bc3
10 changed files with 559 additions and 2 deletions
@@ -135,6 +135,12 @@ export async function handleClientCommandRoute(
metrics.recordCommand("screenshot");
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.SCREENSHOT, targetClientId: targetId, success: true });
} else if (action === "desktop_start") {
try {
requireFeatureAccess(user, "remote_desktop");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
target.ws.send(encodeMessage({ type: "command", commandType: "desktop_start", id: uuidv4() }));
metrics.recordCommand("desktop_start");
} else if (action === "darwin_request_permissions") {
@@ -138,6 +138,23 @@ describe("clients:disconnect gates", () => {
});
});
describe("remote desktop alternate command gates", () => {
test("operator with remote_desktop feature OFF cannot start the desktop stream", async () => {
const auth = await makeUserWithToken("operator");
const clientId = registerTestClient();
setUserFeaturePermission(auth.user.id, "remote_desktop", false);
const res = await command(auth.token, clientId, "desktop_start");
expect(res!.status).toBe(403);
});
test("operator with remote_desktop feature ON can start the desktop stream", async () => {
const auth = await makeUserWithToken("operator");
const clientId = registerTestClient();
const res = await command(auth.token, clientId, "desktop_start");
expect(res!.status).toBe(200);
});
});
describe("clients:reconnect gates", () => {
test("operator (default) can reconnect", async () => {
const auth = await makeUserWithToken("operator");
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, test } from "bun:test";
import { generateToken } from "../../auth";
import {
createUser,
deleteUser,
getUserById,
setUserClientAccessScope,
setUserFeaturePermission,
} from "../../users";
import { handleWebrtcRoutes } from "./webrtc-routes";
const PASSWORD = "Aa1!WebrtcAuthTestPass_2026";
const createdUserIds: number[] = [];
async function makeOperator() {
const username = `wa_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const result = await createUser(username, PASSWORD, "operator", "test");
expect(result.success).toBe(true);
const user = getUserById(result.userId!);
expect(user).not.toBeNull();
createdUserIds.push(user!.id);
setUserClientAccessScope(user!.id, "all");
return { user: user!, token: await generateToken(user!) };
}
afterEach(() => {
while (createdUserIds.length) deleteUser(createdUserIds.pop()!);
});
describe("WebRTC viewer authorization", () => {
test("denies WHEP negotiation when the matching feature is disabled", async () => {
const auth = await makeOperator();
setUserFeaturePermission(auth.user.id, "remote_desktop", false);
const url = new URL("https://localhost/api/webrtc/agents/client-a/desktop/whep");
const response = await handleWebrtcRoutes(
new Request(url, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}` },
body: "offer",
}),
url,
);
expect(response!.status).toBe(403);
});
test("uses the feature corresponding to each media kind", async () => {
const auth = await makeOperator();
setUserFeaturePermission(auth.user.id, "voice", false);
const url = new URL("https://localhost/api/webrtc/agents/client-a/audio/whep");
const response = await handleWebrtcRoutes(
new Request(url, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}` },
body: "offer",
}),
url,
);
expect(response!.status).toBe(403);
});
});
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import { authenticateRequest } from "../../auth";
import { requireClientAccess } from "../../rbac";
import { requireClientAccess, requireFeatureAccess } from "../../rbac";
import { logger } from "../../logger";
import { issueTurnIceServers } from "../turn-credentials";
@@ -27,6 +27,13 @@ export function issueWebrtcPublishToken(clientId: string): string {
export type WebrtcKind = "desktop" | "backstage" | "webcam" | "audio";
const WEBRTC_FEATURES = {
desktop: "remote_desktop",
backstage: "backstage",
webcam: "webcam",
audio: "voice",
} as const;
export function webrtcStreamPathFor(clientId: string, kind: WebrtcKind): string {
return `agents/${clientId}/${kind}`;
}
@@ -78,6 +85,8 @@ export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Respon
if (!user) return new Response("Unauthorized", { status: 401 });
try {
requireClientAccess(user, clientId);
const streamKind = streamPath.split("/")[2] as WebrtcKind;
requireFeatureAccess(user, WEBRTC_FEATURES[streamKind]);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
@@ -24,6 +24,12 @@ import { stopAllProxiesForClient } from "../socks5-proxy-manager";
import { verifyBuildToken, isBuildBanned } from "../build-signing";
import { clearThumbnail } from "../../thumbnails";
import { stopRemoteDesktopRecording } from "../rd-recording";
import {
isAuthenticatedViewerRole,
registerViewerSocket,
unregisterViewerSocket,
validateViewerAuthorization,
} from "../viewer-authorization";
const OFFLINE_GRACE_MS = (() => {
const raw = process.env.OVERLORD_OFFLINE_GRACE_MS;
@@ -324,6 +330,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
const role = ws.data.role as string;
const clientId = ws.data.clientId;
const ip = ws.data.ip;
if (isAuthenticatedViewerRole(ws.data.role) && !registerViewerSocket(ws)) return;
if (role === "dashboard_viewer") return deps.handleDashboardViewerOpen(ws);
if (role === "console_viewer") return deps.handleConsoleViewerOpen(ws);
if (role === "rd_viewer") return deps.handleRemoteDesktopViewerOpen(ws);
@@ -375,6 +382,7 @@ export async function handleWebSocketMessage(
}
const socketRole = ws.data.role as string;
if (isAuthenticatedViewerRole(ws.data.role) && !validateViewerAuthorization(ws)) return;
if (socketRole === "console_viewer") return deps.handleConsoleViewerMessage(ws, message);
if (socketRole === "rd_viewer") return deps.handleRemoteDesktopViewerMessage(ws, message);
if (socketRole === "webcam_viewer") return deps.handleWebcamViewerMessage(ws, message);
@@ -973,6 +981,7 @@ export function handleWebSocketClose(
reason: unknown,
deps: WsLifecycleDeps,
): void {
unregisterViewerSocket(ws);
const clientId = ws.data.clientId;
const role = ws.data.role as string;
const sessionId = ws.data.sessionId;
@@ -226,6 +226,8 @@ async function tryGlobalViewerUpgrade(
const denied = route.authorize?.(user);
if (denied) return denied;
const token = extractTokenFromRequest(req);
return upgradeOrFail(req, server, {
role: route.role,
clientId: "",
@@ -233,6 +235,7 @@ async function tryGlobalViewerUpgrade(
userRole: user.role,
userId: user.userId,
username: user.username,
...(token ? { authTokenHash: hashTokenForSession(token) } : {}),
});
}
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, test } from "bun:test";
import { generateToken } from "../auth";
import { hashTokenForSession, revokeSessionByTokenHash } from "../db";
import type { SocketData } from "../sessions/types";
import {
createUser,
deleteUser,
getUserById,
setUserClientAccessScope,
setUserFeaturePermission,
} from "../users";
import { validateViewerAuthorization } from "./viewer-authorization";
const PASSWORD = "Aa1!ViewerRevalidationTest_2026";
const createdUserIds: number[] = [];
async function makeViewerSocket(role: SocketData["role"], clientId = "client-a") {
const username = `wr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const result = await createUser(username, PASSWORD, "operator", "test");
expect(result.success).toBe(true);
const user = getUserById(result.userId!);
expect(user).not.toBeNull();
createdUserIds.push(user!.id);
setUserClientAccessScope(user!.id, "all");
const token = await generateToken(user!);
const closes: Array<{ code: number; reason: string }> = [];
const ws = {
data: {
role,
clientId,
userId: user!.id,
userRole: user!.role,
username: user!.username,
authTokenHash: hashTokenForSession(token),
},
close(code: number, reason: string) {
closes.push({ code, reason });
},
} as any;
return { user: user!, token, ws, closes };
}
afterEach(() => {
while (createdUserIds.length) deleteUser(createdUserIds.pop()!);
});
describe("live viewer WebSocket authorization", () => {
test("closes a socket after its login session is revoked", async () => {
const auth = await makeViewerSocket("rd_viewer");
expect(validateViewerAuthorization(auth.ws)).toBe(true);
revokeSessionByTokenHash(hashTokenForSession(auth.token));
expect(validateViewerAuthorization(auth.ws)).toBe(false);
expect(auth.closes).toEqual([{ code: 1008, reason: "Authentication expired" }]);
});
test("closes a socket when its feature is revoked after connection", async () => {
const auth = await makeViewerSocket("rd_viewer");
expect(validateViewerAuthorization(auth.ws)).toBe(true);
setUserFeaturePermission(auth.user.id, "remote_desktop", false);
expect(validateViewerAuthorization(auth.ws)).toBe(false);
expect(auth.closes[0]?.reason).toBe("Forbidden: feature access denied");
});
test("closes a socket when client scope is removed after connection", async () => {
const auth = await makeViewerSocket("console_viewer");
expect(validateViewerAuthorization(auth.ws)).toBe(true);
setUserClientAccessScope(auth.user.id, "none");
expect(validateViewerAuthorization(auth.ws)).toBe(false);
expect(auth.closes[0]?.reason).toBe("Forbidden: client access denied");
});
});
@@ -0,0 +1,97 @@
import type { ServerWebSocket } from "bun";
import { getSessionByTokenHash } from "../db";
import { hasPermission } from "../rbac";
import type { SocketData, SocketRole } from "../sessions/types";
import {
canUserAccessClient,
canUserAccessFeature,
getUserById,
type FeatureName,
} from "../users";
const VIEWER_FEATURES: Partial<Record<SocketRole, FeatureName>> = {
console_viewer: "console",
rd_viewer: "remote_desktop",
backstage_viewer: "backstage",
webcam_viewer: "webcam",
file_browser_viewer: "file_browser",
process_viewer: "processes",
keylogger_viewer: "keylogger",
voice_viewer: "voice",
desktop_audio_viewer: "voice",
};
const GLOBAL_VIEWER_ROLES = new Set<SocketRole>([
"notifications_viewer",
"dashboard_viewer",
"chat_viewer",
]);
const activeViewerSockets = new Set<ServerWebSocket<SocketData>>();
function deny(ws: ServerWebSocket<SocketData>, reason: string): false {
activeViewerSockets.delete(ws);
try {
ws.close(1008, reason);
} catch {}
return false;
}
export function isAuthenticatedViewerRole(role: SocketRole): boolean {
return VIEWER_FEATURES[role] !== undefined || GLOBAL_VIEWER_ROLES.has(role);
}
export function validateViewerAuthorization(ws: ServerWebSocket<SocketData>): boolean {
const role = ws.data?.role;
if (!isAuthenticatedViewerRole(role)) return true;
const { authTokenHash, userId } = ws.data;
if (!authTokenHash || userId === undefined) {
return deny(ws, "Authentication expired");
}
const session = getSessionByTokenHash(authTokenHash);
const now = Math.floor(Date.now() / 1000);
if (!session || session.userId !== userId || session.expiresAt <= now) {
return deny(ws, "Authentication expired");
}
const user = getUserById(userId);
if (!user) return deny(ws, "Authentication expired");
ws.data.userRole = user.role;
ws.data.username = user.username;
const feature = VIEWER_FEATURES[role];
if (feature) {
if (!canUserAccessClient(userId, user.role, ws.data.clientId)) {
return deny(ws, "Forbidden: client access denied");
}
if (!canUserAccessFeature(userId, user.role, feature)) {
return deny(ws, "Forbidden: feature access denied");
}
}
if (role === "chat_viewer" && !hasPermission(user.role, "chat:write", userId)) {
return deny(ws, "Forbidden: chat access denied");
}
return true;
}
export function registerViewerSocket(ws: ServerWebSocket<SocketData>): boolean {
if (!validateViewerAuthorization(ws)) return false;
activeViewerSockets.add(ws);
return true;
}
export function unregisterViewerSocket(ws: ServerWebSocket<SocketData>): void {
activeViewerSockets.delete(ws);
}
export function revalidateActiveViewerSockets(): void {
for (const ws of activeViewerSockets) validateViewerAuthorization(ws);
}
const viewerAuthTimer = setInterval(revalidateActiveViewerSockets, 5_000);
(viewerAuthTimer as any).unref?.();