allow easy custom branding in settings + fix some auth shit
This commit is contained in:
@@ -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?.();
|
||||
Reference in New Issue
Block a user