bug fixes.
This commit is contained in:
@@ -1634,7 +1634,15 @@ export function saveSavedScript(
|
|||||||
name: string,
|
name: string,
|
||||||
content: string,
|
content: string,
|
||||||
scriptType: string,
|
scriptType: string,
|
||||||
): SavedScriptRecord {
|
): SavedScriptRecord | null {
|
||||||
|
const owner = db
|
||||||
|
.query<{ user_id: number }, [string]>(
|
||||||
|
`SELECT user_id FROM saved_scripts WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
if (owner && owner.user_id !== userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO saved_scripts (id, user_id, name, content, script_type, created_at, updated_at)
|
`INSERT INTO saved_scripts (id, user_id, name, content, script_type, created_at, updated_at)
|
||||||
@@ -1643,7 +1651,8 @@ export function saveSavedScript(
|
|||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
content = excluded.content,
|
content = excluded.content,
|
||||||
script_type = excluded.script_type,
|
script_type = excluded.script_type,
|
||||||
updated_at = excluded.updated_at`,
|
updated_at = excluded.updated_at
|
||||||
|
WHERE saved_scripts.user_id = excluded.user_id`,
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
@@ -2058,6 +2067,15 @@ export function deletePushSubscription(endpoint: string): void {
|
|||||||
db.run(`DELETE FROM push_subscriptions WHERE endpoint=?`, endpoint);
|
db.run(`DELETE FROM push_subscriptions WHERE endpoint=?`, endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deletePushSubscriptionForUser(userId: number, endpoint: string): boolean {
|
||||||
|
const result = db.run(
|
||||||
|
`DELETE FROM push_subscriptions WHERE endpoint=? AND user_id=?`,
|
||||||
|
endpoint,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return ((result as any)?.changes || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function deletePushSubscriptionsByUser(userId: number): void {
|
export function deletePushSubscriptionsByUser(userId: number): void {
|
||||||
db.run(`DELETE FROM push_subscriptions WHERE user_id=?`, userId);
|
db.run(`DELETE FROM push_subscriptions WHERE user_id=?`, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { authenticateRequest } from "./auth";
|
import { authenticateRequest } from "./auth";
|
||||||
import { loadConfig, getConfig } from "./config";
|
import { loadConfig, getConfig } from "./config";
|
||||||
import { flushAuditLogsSync } from "./auditLog";
|
import { flushAuditLogsSync } from "./auditLog";
|
||||||
import { getUserById, getUsersForNotificationDelivery, isClientOwnedByUser, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope, hasPermission } from "./users";
|
import { getUserById, getUsersForNotificationDelivery, getUsersForNotificationDeliveryByClientOwnership, isClientOwnedByUser, canUserAccessClient, setUserClientAccessRule, setUserClientAccessScope, getUserClientAccessScope, hasPermission } from "./users";
|
||||||
import { requireAuth, requirePermission } from "./rbac";
|
import { requireAuth, requirePermission } from "./rbac";
|
||||||
import { metrics } from "./metrics";
|
import { metrics } from "./metrics";
|
||||||
import { ensureDataDir } from "./paths";
|
import { ensureDataDir } from "./paths";
|
||||||
@@ -342,6 +342,7 @@ const notificationPluginHandlers = createNotificationPluginHandlers({
|
|||||||
pluginState,
|
pluginState,
|
||||||
getNotificationConfig,
|
getNotificationConfig,
|
||||||
canUserAccessClient: canUserAccessClient as (userId: number, userRole: string, clientId: string) => boolean,
|
canUserAccessClient: canUserAccessClient as (userId: number, userRole: string, clientId: string) => boolean,
|
||||||
|
isClientOwnedByUser,
|
||||||
getUserRole: (userId: number) => getUserById(userId)?.role,
|
getUserRole: (userId: number) => getUserById(userId)?.role,
|
||||||
isClientNotificationsMuted,
|
isClientNotificationsMuted,
|
||||||
storeNotificationScreenshot: storeNotificationScreenshotForPending,
|
storeNotificationScreenshot: storeNotificationScreenshotForPending,
|
||||||
@@ -349,14 +350,12 @@ const notificationPluginHandlers = createNotificationPluginHandlers({
|
|||||||
forwardPluginEventToRuntime: (clientId, pluginId, event, payload) =>
|
forwardPluginEventToRuntime: (clientId, pluginId, event, payload) =>
|
||||||
pluginRuntime.dispatchClientEvent(clientId, pluginId, event, payload),
|
pluginRuntime.dispatchClientEvent(clientId, pluginId, event, payload),
|
||||||
getDeliveryTargetsForClientEvent: (event: string, clientId: string): UserDeliveryTarget[] => {
|
getDeliveryTargetsForClientEvent: (event: string, clientId: string): UserDeliveryTarget[] => {
|
||||||
const all = getUsersForNotificationDelivery();
|
|
||||||
let filtered: typeof all;
|
|
||||||
if (event === "client_purgatory") {
|
if (event === "client_purgatory") {
|
||||||
filtered = all.filter((u) => u.role === "admin" || u.role === "operator");
|
return getUsersForNotificationDeliveryByClientOwnership(clientId).map(toDeliveryTarget);
|
||||||
} else {
|
|
||||||
filtered = all.filter((u) => canUserAccessClient(u.id, u.role, clientId));
|
|
||||||
}
|
}
|
||||||
return filtered.map(toDeliveryTarget);
|
return getUsersForNotificationDelivery()
|
||||||
|
.filter((u) => canUserAccessClient(u.id, u.role, clientId))
|
||||||
|
.map(toDeliveryTarget);
|
||||||
},
|
},
|
||||||
savePluginState,
|
savePluginState,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -178,7 +178,6 @@ describe("getPermissionDescription", () => {
|
|||||||
const cases: [Permission, string][] = [
|
const cases: [Permission, string][] = [
|
||||||
["users:manage", "Manage users and roles"],
|
["users:manage", "Manage users and roles"],
|
||||||
["clients:control", "Control clients (execute commands, desktop, console, files)"],
|
["clients:control", "Control clients (execute commands, desktop, console, files)"],
|
||||||
["clients:view", "View connected clients"],
|
|
||||||
["clients:build", "Build client binaries"],
|
["clients:build", "Build client binaries"],
|
||||||
["clients:enroll", "Manage client enrollment approvals"],
|
["clients:enroll", "Manage client enrollment approvals"],
|
||||||
["audit:view", "View audit logs"],
|
["audit:view", "View audit logs"],
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { hasPermission, type UserRole } from "./users";
|
|||||||
export type Permission =
|
export type Permission =
|
||||||
| "users:manage"
|
| "users:manage"
|
||||||
| "clients:control"
|
| "clients:control"
|
||||||
| "clients:view"
|
|
||||||
| "clients:build"
|
| "clients:build"
|
||||||
| "clients:enroll"
|
| "clients:enroll"
|
||||||
| "audit:view"
|
| "audit:view"
|
||||||
@@ -73,8 +72,6 @@ export function getPermissionDescription(permission: Permission): string {
|
|||||||
return "Manage users and roles";
|
return "Manage users and roles";
|
||||||
case "clients:control":
|
case "clients:control":
|
||||||
return "Control clients (execute commands, desktop, console, files)";
|
return "Control clients (execute commands, desktop, console, files)";
|
||||||
case "clients:view":
|
|
||||||
return "View connected clients";
|
|
||||||
case "clients:build":
|
case "clients:build":
|
||||||
return "Build client binaries";
|
return "Build client binaries";
|
||||||
case "clients:enroll":
|
case "clients:enroll":
|
||||||
@@ -107,7 +104,6 @@ export function getRolePermissions(role: UserRole): Permission[] {
|
|||||||
if (hasPermission(role, "users:manage")) permissions.push("users:manage");
|
if (hasPermission(role, "users:manage")) permissions.push("users:manage");
|
||||||
if (hasPermission(role, "clients:control"))
|
if (hasPermission(role, "clients:control"))
|
||||||
permissions.push("clients:control");
|
permissions.push("clients:control");
|
||||||
if (hasPermission(role, "clients:view")) permissions.push("clients:view");
|
|
||||||
if (hasPermission(role, "clients:build")) permissions.push("clients:build");
|
if (hasPermission(role, "clients:build")) permissions.push("clients:build");
|
||||||
if (hasPermission(role, "clients:enroll")) permissions.push("clients:enroll");
|
if (hasPermission(role, "clients:enroll")) permissions.push("clients:enroll");
|
||||||
if (hasPermission(role, "audit:view")) permissions.push("audit:view");
|
if (hasPermission(role, "audit:view")) permissions.push("audit:view");
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ export const DEFAULT_TELEGRAM_TEMPLATE =
|
|||||||
const NOTIFICATION_SCREENSHOT_WAIT_MS = 5_000;
|
const NOTIFICATION_SCREENSHOT_WAIT_MS = 5_000;
|
||||||
const NOTIFICATION_SCREENSHOT_POLL_MS = 250;
|
const NOTIFICATION_SCREENSHOT_POLL_MS = 250;
|
||||||
|
|
||||||
|
export function isPrivateOrInternalHostname(hostname: string): boolean {
|
||||||
|
const h = hostname.toLowerCase();
|
||||||
|
const BLOCKED_HOSTS = ["localhost", "metadata.google.internal", "169.254.169.254"];
|
||||||
|
return (
|
||||||
|
BLOCKED_HOSTS.includes(h) ||
|
||||||
|
h.endsWith(".internal") ||
|
||||||
|
h.startsWith("127.") ||
|
||||||
|
h === "[::1]" ||
|
||||||
|
/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(h) ||
|
||||||
|
h.startsWith("169.254.") ||
|
||||||
|
h.startsWith("0.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getScreenshotMeta(format: string | undefined): { contentType: string; ext: string } {
|
function getScreenshotMeta(format: string | undefined): { contentType: string; ext: string } {
|
||||||
const normalized = (format || "jpeg").toLowerCase();
|
const normalized = (format || "jpeg").toLowerCase();
|
||||||
if (normalized === "png") return { contentType: "image/png", ext: "png" };
|
if (normalized === "png") return { contentType: "image/png", ext: "png" };
|
||||||
@@ -175,16 +189,7 @@ async function deliverToUserWebhook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hostname = parsed.hostname.toLowerCase();
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
const BLOCKED_HOSTS = ["localhost", "metadata.google.internal", "169.254.169.254"];
|
if (isPrivateOrInternalHostname(hostname)) {
|
||||||
if (
|
|
||||||
BLOCKED_HOSTS.includes(hostname) ||
|
|
||||||
hostname.endsWith(".internal") ||
|
|
||||||
hostname.startsWith("127.") ||
|
|
||||||
hostname === "[::1]" ||
|
|
||||||
/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname) ||
|
|
||||||
hostname.startsWith("169.254.") ||
|
|
||||||
hostname.startsWith("0.")
|
|
||||||
) {
|
|
||||||
logger.warn(`[notify] blocked webhook to private/internal address: ${hostname}`);
|
logger.warn(`[notify] blocked webhook to private/internal address: ${hostname}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -359,6 +364,7 @@ export async function deliverWebPushClientEvent(
|
|||||||
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean,
|
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean,
|
||||||
getUserRole: (userId: number) => string | undefined,
|
getUserRole: (userId: number) => string | undefined,
|
||||||
isClientEventPushEnabled?: (userId: number) => boolean,
|
isClientEventPushEnabled?: (userId: number) => boolean,
|
||||||
|
isClientOwnedByUser?: (userId: number, clientId: string) => boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subs = getAllPushSubscriptions();
|
const subs = getAllPushSubscriptions();
|
||||||
if (subs.length === 0) return;
|
if (subs.length === 0) return;
|
||||||
@@ -393,7 +399,14 @@ export async function deliverWebPushClientEvent(
|
|||||||
if (!role) return;
|
if (!role) return;
|
||||||
if (isClientEventPushEnabled && !isClientEventPushEnabled(sub.userId)) return;
|
if (isClientEventPushEnabled && !isClientEventPushEnabled(sub.userId)) return;
|
||||||
if (event === "client_purgatory") {
|
if (event === "client_purgatory") {
|
||||||
if (role !== "admin" && role !== "operator") return;
|
if (role === "admin") {
|
||||||
|
} else if (role === "operator") {
|
||||||
|
if (!isClientOwnedByUser || !isClientOwnedByUser(sub.userId, info.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if (role !== "admin") {
|
} else if (role !== "admin") {
|
||||||
if (!canUserAccessClient(sub.userId, role, info.id)) return;
|
if (!canUserAccessClient(sub.userId, role, info.id)) return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export async function handleChatRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(user.role, "chat:write", user.userId)) {
|
||||||
|
return new Response("Forbidden: Chat access denied", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const beforeParam = url.searchParams.get("before");
|
const beforeParam = url.searchParams.get("before");
|
||||||
const limitParam = url.searchParams.get("limit");
|
const limitParam = url.searchParams.get("limit");
|
||||||
const before = beforeParam ? Number(beforeParam) : undefined;
|
const before = beforeParam ? Number(beforeParam) : undefined;
|
||||||
|
|||||||
@@ -158,11 +158,8 @@ export async function handleClientRoutes(
|
|||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
try {
|
if (user.role !== "admin") {
|
||||||
requirePermission(user, "clients:control");
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Response) return error;
|
|
||||||
return new Response("Forbidden", { status: 403 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
|
|||||||
@@ -346,11 +346,8 @@ export async function handleEnrollmentRoutes(
|
|||||||
if (req.method === "GET" && url.pathname === "/api/enrollment/banned-ips") {
|
if (req.method === "GET" && url.pathname === "/api/enrollment/banned-ips") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
try {
|
if (user.role !== "admin") {
|
||||||
requirePermission(user, "clients:enroll");
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Response) return error;
|
|
||||||
return new Response("Forbidden", { status: 403 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ items: listBannedIps() });
|
return Response.json({ items: listBannedIps() });
|
||||||
@@ -360,11 +357,8 @@ export async function handleEnrollmentRoutes(
|
|||||||
if (req.method === "DELETE" && url.pathname === "/api/enrollment/banned-ips") {
|
if (req.method === "DELETE" && url.pathname === "/api/enrollment/banned-ips") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
try {
|
if (user.role !== "admin") {
|
||||||
requirePermission(user, "clients:enroll");
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Response) return error;
|
|
||||||
return new Response("Forbidden", { status: 403 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ipToUnban = (url.searchParams.get("ip") || "").trim();
|
const ipToUnban = (url.searchParams.get("ip") || "").trim();
|
||||||
@@ -390,11 +384,8 @@ export async function handleEnrollmentRoutes(
|
|||||||
if (req.method === "POST" && url.pathname === "/api/enrollment/ban-ip") {
|
if (req.method === "POST" && url.pathname === "/api/enrollment/ban-ip") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||||
try {
|
if (user.role !== "admin") {
|
||||||
requirePermission(user, "clients:enroll");
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Response) return error;
|
|
||||||
return new Response("Forbidden", { status: 403 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: any;
|
let body: any;
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import { authenticateRequest } from "../../auth";
|
|||||||
import { AuditAction, logAudit } from "../../auditLog";
|
import { AuditAction, logAudit } from "../../auditLog";
|
||||||
import * as clientManager from "../../clientManager";
|
import * as clientManager from "../../clientManager";
|
||||||
import { getConfig, updateNotificationsConfig } from "../../config";
|
import { getConfig, updateNotificationsConfig } from "../../config";
|
||||||
import { savePushSubscription, deletePushSubscription, getPushSubscriptionsByUser } from "../../db";
|
import { savePushSubscription, deletePushSubscriptionForUser, getPushSubscriptionsByUser } from "../../db";
|
||||||
import { encodeMessage } from "../../protocol";
|
import { encodeMessage } from "../../protocol";
|
||||||
import {
|
import {
|
||||||
|
canUserAccessClient,
|
||||||
getUserNotificationSettings,
|
getUserNotificationSettings,
|
||||||
updateUserNotificationSettings,
|
updateUserNotificationSettings,
|
||||||
} from "../../users";
|
} from "../../users";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WEBHOOK_TEMPLATE,
|
DEFAULT_WEBHOOK_TEMPLATE,
|
||||||
DEFAULT_TELEGRAM_TEMPLATE,
|
DEFAULT_TELEGRAM_TEMPLATE,
|
||||||
|
isPrivateOrInternalHostname,
|
||||||
renderNotificationTemplate,
|
renderNotificationTemplate,
|
||||||
} from "../notification-delivery";
|
} from "../notification-delivery";
|
||||||
import { getVapidPublicKey } from "../web-push";
|
import { getVapidPublicKey } from "../web-push";
|
||||||
@@ -20,7 +22,7 @@ type RequestIpProvider = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type NotificationsRouteDeps = {
|
type NotificationsRouteDeps = {
|
||||||
getNotificationScreenshot: (notificationId: string) => { format?: string; bytes: Uint8Array } | null;
|
getNotificationScreenshot: (notificationId: string) => { format?: string; bytes: Uint8Array; clientId?: string } | null;
|
||||||
secureHeaders: (contentType?: string) => HeadersInit;
|
secureHeaders: (contentType?: string) => HeadersInit;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,6 +47,13 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
screenshot.clientId &&
|
||||||
|
!canUserAccessClient(user.userId, user.role, screenshot.clientId)
|
||||||
|
) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const format = (screenshot.format || "jpeg").toLowerCase();
|
const format = (screenshot.format || "jpeg").toLowerCase();
|
||||||
const contentType = format === "jpg" || format === "jpeg"
|
const contentType = format === "jpg" || format === "jpeg"
|
||||||
? "image/jpeg"
|
? "image/jpeg"
|
||||||
@@ -67,8 +76,8 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (user.role === "viewer") {
|
if (user.role !== "admin") {
|
||||||
return new Response("Forbidden: Viewer access denied", { status: 403 });
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
}
|
}
|
||||||
return Response.json({ notifications: getConfig().notifications });
|
return Response.json({ notifications: getConfig().notifications });
|
||||||
}
|
}
|
||||||
@@ -348,6 +357,13 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
return Response.json({ error: "Invalid webhook URL" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPrivateOrInternalHostname(parsed.hostname.toLowerCase())) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Webhook URL points at a private/internal address" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const sampleRecord = {
|
const sampleRecord = {
|
||||||
id: "preview-notification",
|
id: "preview-notification",
|
||||||
clientId: "abc123def456",
|
clientId: "abc123def456",
|
||||||
@@ -489,7 +505,7 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
return Response.json({ error: "Missing endpoint" }, { status: 400 });
|
return Response.json({ error: "Missing endpoint" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
deletePushSubscription(endpoint);
|
deletePushSubscriptionForUser(user.userId, endpoint);
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ export async function handleSavedScriptsRoutes(
|
|||||||
: "powershell";
|
: "powershell";
|
||||||
|
|
||||||
const item = saveSavedScript(id, user.userId, name, content, scriptType);
|
const item = saveSavedScript(id, user.userId, name, content, scriptType);
|
||||||
|
if (!item) {
|
||||||
|
return Response.json({ error: "Script id already in use" }, { status: 409 });
|
||||||
|
}
|
||||||
return Response.json({ ok: true, item });
|
return Response.json({ ok: true, item });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { logger } from "../../logger";
|
import { logger } from "../../logger";
|
||||||
import { isIpBanned } from "../../db";
|
import { isIpBanned } from "../../db";
|
||||||
import { canUserAccessClient, canUserAccessFeature } from "../../users";
|
import { canUserAccessClient, canUserAccessFeature, hasPermission } from "../../users";
|
||||||
import type { FeatureName } from "../../users";
|
import type { FeatureName } from "../../users";
|
||||||
|
|
||||||
type RequestServer = {
|
type RequestServer = {
|
||||||
@@ -296,6 +296,9 @@ export async function handleWsUpgradeRoutes(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
if (!hasPermission(user.role, "chat:write", user.userId)) {
|
||||||
|
return new Response("Forbidden: Chat access denied", { status: 403 });
|
||||||
|
}
|
||||||
const ip = server.requestIP(req)?.address || "";
|
const ip = server.requestIP(req)?.address || "";
|
||||||
if (server.upgrade(req, { data: { role: "chat_viewer", clientId: "", ip, userRole: user.role, userId: user.userId } })) {
|
if (server.upgrade(req, { data: { role: "chat_viewer", clientId: "", ip, userRole: user.role, userId: user.userId } })) {
|
||||||
return new Response();
|
return new Response();
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type CreateDeps = {
|
|||||||
pluginState: { enabled: Record<string, boolean>; lastError: Record<string, string> };
|
pluginState: { enabled: Record<string, boolean>; lastError: Record<string, string> };
|
||||||
getNotificationConfig: () => NotificationConfigShape;
|
getNotificationConfig: () => NotificationConfigShape;
|
||||||
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean;
|
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean;
|
||||||
|
isClientOwnedByUser: (userId: number, clientId: string) => boolean;
|
||||||
getUserRole: (userId: number) => string | undefined;
|
getUserRole: (userId: number) => string | undefined;
|
||||||
isClientNotificationsMuted: (clientId: string) => boolean;
|
isClientNotificationsMuted: (clientId: string) => boolean;
|
||||||
storeNotificationScreenshot: (
|
storeNotificationScreenshot: (
|
||||||
@@ -459,7 +460,14 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
|
|||||||
const sRole = session.userRole ?? session.viewer.data.userRole ?? "";
|
const sRole = session.userRole ?? session.viewer.data.userRole ?? "";
|
||||||
const sUserId = session.userId ?? session.viewer.data.userId;
|
const sUserId = session.userId ?? session.viewer.data.userId;
|
||||||
if (event === "client_purgatory") {
|
if (event === "client_purgatory") {
|
||||||
if (sRole !== "admin" && sRole !== "operator") continue;
|
if (sRole === "admin") {
|
||||||
|
} else if (sRole === "operator") {
|
||||||
|
if (sUserId === undefined || !deps.isClientOwnedByUser(sUserId, info.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (sRole !== "admin") {
|
if (sRole !== "admin") {
|
||||||
if (sUserId === undefined || !deps.canUserAccessClient(sUserId, sRole, info.id)) {
|
if (sUserId === undefined || !deps.canUserAccessClient(sUserId, sRole, info.id)) {
|
||||||
@@ -473,10 +481,17 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
|
|||||||
const externalTargets = deps.getDeliveryTargetsForClientEvent(event, info.id);
|
const externalTargets = deps.getDeliveryTargetsForClientEvent(event, info.id);
|
||||||
const pushEnabledByUser = new Map(externalTargets.map((t) => [t.userId, t.clientEventPush]));
|
const pushEnabledByUser = new Map(externalTargets.map((t) => [t.userId, t.clientEventPush]));
|
||||||
|
|
||||||
deliverWebPushClientEvent(event, info, deps.canUserAccessClient, deps.getUserRole, (userId) => {
|
deliverWebPushClientEvent(
|
||||||
const enabled = pushEnabledByUser.get(userId);
|
event,
|
||||||
return enabled !== undefined ? enabled : true;
|
info,
|
||||||
}).catch((err) => logger.warn("[notify] web push client event delivery failed", err));
|
deps.canUserAccessClient,
|
||||||
|
deps.getUserRole,
|
||||||
|
(userId) => {
|
||||||
|
const enabled = pushEnabledByUser.get(userId);
|
||||||
|
return enabled !== undefined ? enabled : true;
|
||||||
|
},
|
||||||
|
deps.isClientOwnedByUser,
|
||||||
|
).catch((err) => logger.warn("[notify] web push client event delivery failed", err));
|
||||||
|
|
||||||
deliverClientEventToExternalChannels(event, info, externalTargets).catch((err) =>
|
deliverClientEventToExternalChannels(event, info, externalTargets).catch((err) =>
|
||||||
logger.warn("[notify] external channel client event delivery failed", err),
|
logger.warn("[notify] external channel client event delivery failed", err),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
canUserAccessClient,
|
canUserAccessClient,
|
||||||
canViewClients,
|
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
@@ -34,12 +33,6 @@ function cleanupCreatedUsers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("users client access RBAC", () => {
|
describe("users client access RBAC", () => {
|
||||||
test("clients:view permission is admin-only by default", () => {
|
|
||||||
expect(canViewClients("admin")).toBe(true);
|
|
||||||
expect(canViewClients("operator")).toBe(false);
|
|
||||||
expect(canViewClients("viewer")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("new non-admin users default to no client visibility", async () => {
|
test("new non-admin users default to no client visibility", async () => {
|
||||||
try {
|
try {
|
||||||
const viewer = await createTempUser("viewer");
|
const viewer = await createTempUser("viewer");
|
||||||
|
|||||||
@@ -973,10 +973,6 @@ export function canControlClients(role: UserRole): boolean {
|
|||||||
return role === "admin" || role === "operator";
|
return role === "admin" || role === "operator";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canViewClients(role: UserRole): boolean {
|
|
||||||
return role === "admin";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canBuildClients(userId: number, role: UserRole): boolean {
|
export function canBuildClients(userId: number, role: UserRole): boolean {
|
||||||
if (role === "admin") return true;
|
if (role === "admin") return true;
|
||||||
const user = getUserById(userId);
|
const user = getUserById(userId);
|
||||||
@@ -997,8 +993,6 @@ export function hasPermission(role: UserRole, permission: string, userId?: numbe
|
|||||||
return canManageUsers(role);
|
return canManageUsers(role);
|
||||||
case "clients:control":
|
case "clients:control":
|
||||||
return canControlClients(role);
|
return canControlClients(role);
|
||||||
case "clients:view":
|
|
||||||
return canViewClients(role);
|
|
||||||
case "clients:build":
|
case "clients:build":
|
||||||
if (userId !== undefined) return canBuildClients(userId, role);
|
if (userId !== undefined) return canBuildClients(userId, role);
|
||||||
return role === "admin" || role === "operator";
|
return role === "admin" || role === "operator";
|
||||||
|
|||||||
Reference in New Issue
Block a user