small bug fixes
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { extractTokenFromCookie, extractTokenFromHeader, generateToken } from "./auth";
|
import { authenticateRequest, extractTokenFromCookie, extractTokenFromHeader, generateToken } from "./auth";
|
||||||
import { getConfig, updateAppearanceConfig, type Config } from "./config";
|
import { getConfig, updateAppearanceConfig, type Config } from "./config";
|
||||||
import { generateTotpCode } from "./mfa";
|
import { generateTotpCode } from "./mfa";
|
||||||
import { getBrandingImage } from "./db";
|
import { getBrandingImage } from "./db";
|
||||||
@@ -46,6 +46,39 @@ describe("auth token extraction", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("forced password change authentication", () => {
|
||||||
|
test("a pending account can only use identity, logout, and its own password endpoint", async () => {
|
||||||
|
const username = `forced_password_${Date.now().toString(36)}`;
|
||||||
|
const created = await createUser(username, PASSWORD, "operator", "test", true);
|
||||||
|
expect(created.success).toBe(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = getUserById(created.userId!);
|
||||||
|
const token = await generateToken(user!);
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
const blocked = await authenticateRequest(new Request("https://localhost/api/clients", { headers }));
|
||||||
|
expect(blocked).toBeNull();
|
||||||
|
|
||||||
|
const me = await authenticateRequest(new Request("https://localhost/api/auth/me", { headers }));
|
||||||
|
expect(me?.userId).toBe(created.userId!);
|
||||||
|
expect(me?.mustChangePassword).toBe(true);
|
||||||
|
|
||||||
|
const ownPassword = await authenticateRequest(
|
||||||
|
new Request(`https://localhost/api/users/${created.userId}/password`, { headers }),
|
||||||
|
);
|
||||||
|
expect(ownPassword?.userId).toBe(created.userId!);
|
||||||
|
|
||||||
|
const otherPassword = await authenticateRequest(
|
||||||
|
new Request("https://localhost/api/users/1/password", { headers }),
|
||||||
|
);
|
||||||
|
expect(otherPassword).toBeNull();
|
||||||
|
} finally {
|
||||||
|
deleteUser(created.userId!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("account onboarding", () => {
|
describe("account onboarding", () => {
|
||||||
test("is pending for a new account and remains completed at the account level", async () => {
|
test("is pending for a new account and remains completed at the account level", async () => {
|
||||||
const username = `onboarding_${Date.now().toString(36)}`;
|
const username = `onboarding_${Date.now().toString(36)}`;
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ export interface AuthenticatedUser {
|
|||||||
username: string;
|
username: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
mustChangePassword?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mayUseApiBeforePasswordChange(pathname: string, userId: number): boolean {
|
||||||
|
return pathname === "/api/auth/me"
|
||||||
|
|| pathname === "/api/auth/logout"
|
||||||
|
|| pathname === `/api/users/${userId}/password`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateToken(
|
export async function generateToken(
|
||||||
@@ -244,6 +251,15 @@ export async function authenticateRequest(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pathname = new URL(req.url).pathname;
|
||||||
|
if (
|
||||||
|
Boolean(user.must_change_password)
|
||||||
|
&& pathname.startsWith("/api/")
|
||||||
|
&& !mayUseApiBeforePasswordChange(pathname, user.id)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastUpdate = lastActivityUpdate.get(token) || 0;
|
const lastUpdate = lastActivityUpdate.get(token) || 0;
|
||||||
if (now - lastUpdate > SESSION_ACTIVITY_THROTTLE) {
|
if (now - lastUpdate > SESSION_ACTIVITY_THROTTLE) {
|
||||||
@@ -257,6 +273,7 @@ export async function authenticateRequest(
|
|||||||
username: payload.sub,
|
username: payload.sub,
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
mustChangePassword: Boolean(user.must_change_password),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,34 @@ export function deleteOfflineClientRows(): number {
|
|||||||
return (result as any)?.changes || 0;
|
return (result as any)?.changes || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteOfflineClientRowsForScope(
|
||||||
|
allowedClientIds?: string[],
|
||||||
|
deniedClientIds?: string[],
|
||||||
|
): number {
|
||||||
|
if (allowedClientIds) {
|
||||||
|
if (allowedClientIds.length === 0) return 0;
|
||||||
|
const placeholders = allowedClientIds.map(() => "?").join(",");
|
||||||
|
const result = db.run(
|
||||||
|
`DELETE FROM clients WHERE online=0 AND id IN (${placeholders})`,
|
||||||
|
...allowedClientIds,
|
||||||
|
);
|
||||||
|
invalidateClientMetricsSummaryCache();
|
||||||
|
return (result as any)?.changes || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deniedClientIds && deniedClientIds.length > 0) {
|
||||||
|
const placeholders = deniedClientIds.map(() => "?").join(",");
|
||||||
|
const result = db.run(
|
||||||
|
`DELETE FROM clients WHERE online=0 AND id NOT IN (${placeholders})`,
|
||||||
|
...deniedClientIds,
|
||||||
|
);
|
||||||
|
invalidateClientMetricsSummaryCache();
|
||||||
|
return (result as any)?.changes || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteOfflineClientRows();
|
||||||
|
}
|
||||||
|
|
||||||
export function getClientOnlineState(id: string): boolean | null {
|
export function getClientOnlineState(id: string): boolean | null {
|
||||||
const row = db.query<{ online: number }>(`SELECT online FROM clients WHERE id=?`).get(id);
|
const row = db.query<{ online: number }>(`SELECT online FROM clients WHERE id=?`).get(id);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -1271,15 +1299,31 @@ export function getClientPublicKeyById(id: string): string | null {
|
|||||||
return row?.public_key ?? null;
|
return row?.public_key ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listDistinctCountries(): { code: string; count: number }[] {
|
export function listDistinctCountries(
|
||||||
|
allowedClientIds?: string[],
|
||||||
|
deniedClientIds?: string[],
|
||||||
|
): { code: string; count: number }[] {
|
||||||
|
if (allowedClientIds && allowedClientIds.length === 0) return [];
|
||||||
|
|
||||||
|
const clauses: string[] = [];
|
||||||
|
const params: string[] = [];
|
||||||
|
if (allowedClientIds) {
|
||||||
|
clauses.push(`id IN (${allowedClientIds.map(() => "?").join(",")})`);
|
||||||
|
params.push(...allowedClientIds);
|
||||||
|
} else if (deniedClientIds && deniedClientIds.length > 0) {
|
||||||
|
clauses.push(`id NOT IN (${deniedClientIds.map(() => "?").join(",")})`);
|
||||||
|
params.push(...deniedClientIds);
|
||||||
|
}
|
||||||
|
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||||
const rows = db
|
const rows = db
|
||||||
.query<{ code: string; count: number }>(
|
.query<{ code: string; count: number }>(
|
||||||
`SELECT UPPER(COALESCE(NULLIF(country, ''), 'ZZ')) as code, COUNT(*) as count
|
`SELECT UPPER(COALESCE(NULLIF(country, ''), 'ZZ')) as code, COUNT(*) as count
|
||||||
FROM clients
|
FROM clients
|
||||||
|
${where}
|
||||||
GROUP BY UPPER(COALESCE(NULLIF(country, ''), 'ZZ'))
|
GROUP BY UPPER(COALESCE(NULLIF(country, ''), 'ZZ'))
|
||||||
ORDER BY count DESC`,
|
ORDER BY count DESC`,
|
||||||
)
|
)
|
||||||
.all();
|
.all(...params);
|
||||||
return rows.map((r) => ({ code: r.code, count: Number(r.count) || 0 }));
|
return rows.map((r) => ({ code: r.code, count: Number(r.count) || 0 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,12 @@ export async function handleClientCommandRoute(
|
|||||||
return Response.json({ ok: false, error: error.message || "macOS permission request failed" }, { status: 504 });
|
return Response.json({ ok: false, error: error.message || "macOS permission request failed" }, { status: 504 });
|
||||||
}
|
}
|
||||||
} else if (action === "script_exec") {
|
} else if (action === "script_exec") {
|
||||||
|
try {
|
||||||
|
requireFeatureAccess(user, "console");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Response) return error;
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
const scriptContent = body?.script || "";
|
const scriptContent = body?.script || "";
|
||||||
const scriptType = body?.scriptType || "powershell";
|
const scriptType = body?.scriptType || "powershell";
|
||||||
const cmdId = uuidv4();
|
const cmdId = uuidv4();
|
||||||
@@ -213,6 +219,12 @@ export async function handleClientCommandRoute(
|
|||||||
return Response.json({ ok: false, error: error.message }, { status: 500 });
|
return Response.json({ ok: false, error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
} else if (action === "voice_capabilities") {
|
} else if (action === "voice_capabilities") {
|
||||||
|
try {
|
||||||
|
requireFeatureAccess(user, "voice");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Response) return error;
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
const cmdId = uuidv4();
|
const cmdId = uuidv4();
|
||||||
const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => {
|
const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
deleteUser,
|
deleteUser,
|
||||||
getUserById,
|
getUserById,
|
||||||
setUserClientAccessScope,
|
setUserClientAccessScope,
|
||||||
|
setUserClientAccessRule,
|
||||||
|
getUserClientAccessScope,
|
||||||
|
listUserClientRuleIdsByAccess,
|
||||||
setUserFeaturePermission,
|
setUserFeaturePermission,
|
||||||
} from "../../users";
|
} from "../../users";
|
||||||
import { upsertClientRow, deleteClientRow } from "../../db";
|
import { upsertClientRow, deleteClientRow } from "../../db";
|
||||||
@@ -169,6 +172,75 @@ describe("remote desktop alternate command gates", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("alternate command feature gates", () => {
|
||||||
|
test("operator with console feature OFF cannot execute a saved script through the HTTP command route", async () => {
|
||||||
|
const auth = await makeUserWithToken("operator");
|
||||||
|
const clientId = registerTestClient();
|
||||||
|
setUserFeaturePermission(auth.user.id, "console", false);
|
||||||
|
const res = await command(auth.token, clientId, "script_exec", { script: "echo blocked" });
|
||||||
|
expect(res!.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("operator with voice feature OFF cannot probe voice capabilities through the HTTP command route", async () => {
|
||||||
|
const auth = await makeUserWithToken("operator");
|
||||||
|
const clientId = registerTestClient();
|
||||||
|
setUserFeaturePermission(auth.user.id, "voice", false);
|
||||||
|
const res = await command(auth.token, clientId, "voice_capabilities");
|
||||||
|
expect(res!.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("client scope aggregation and bulk mutation gates", () => {
|
||||||
|
test("country aggregation only includes clients in an operator's allowlist", async () => {
|
||||||
|
const auth = await makeUserWithToken("operator", false);
|
||||||
|
const allowedId = registerTestClient();
|
||||||
|
const deniedId = registerTestClient();
|
||||||
|
upsertClientRow({ id: allowedId, country: "Q1" });
|
||||||
|
upsertClientRow({ id: deniedId, country: "Q2" });
|
||||||
|
setUserClientAccessScope(auth.user.id, "allowlist");
|
||||||
|
setUserClientAccessRule(auth.user.id, allowedId, "allow");
|
||||||
|
expect(getUserClientAccessScope(auth.user.id)).toBe("allowlist");
|
||||||
|
expect(listUserClientRuleIdsByAccess(auth.user.id, "allow")).toEqual([allowedId]);
|
||||||
|
|
||||||
|
const url = new URL("https://localhost/api/clients/countries");
|
||||||
|
const res = await handleClientRoutes(
|
||||||
|
new Request(url, { headers: { Authorization: `Bearer ${auth.token}` } }),
|
||||||
|
url,
|
||||||
|
mockServer,
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
const body = await res!.json();
|
||||||
|
expect(body.countries).toEqual([{ code: "Q1", count: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bulk offline cleanup cannot delete a client outside an operator's allowlist", async () => {
|
||||||
|
const auth = await makeUserWithToken("operator", false);
|
||||||
|
const allowedId = registerTestClient();
|
||||||
|
const deniedId = registerTestClient();
|
||||||
|
clientManager.deleteClient(allowedId);
|
||||||
|
clientManager.deleteClient(deniedId);
|
||||||
|
upsertClientRow({ id: allowedId, online: 0 });
|
||||||
|
upsertClientRow({ id: deniedId, online: 0 });
|
||||||
|
setUserClientAccessScope(auth.user.id, "allowlist");
|
||||||
|
setUserClientAccessRule(auth.user.id, allowedId, "allow");
|
||||||
|
expect(getUserClientAccessScope(auth.user.id)).toBe("allowlist");
|
||||||
|
expect(listUserClientRuleIdsByAccess(auth.user.id, "allow")).toEqual([allowedId]);
|
||||||
|
|
||||||
|
const url = new URL("https://localhost/api/clients/offline");
|
||||||
|
const res = await handleClientRoutes(
|
||||||
|
new Request(url, { method: "DELETE", headers: { Authorization: `Bearer ${auth.token}` } }),
|
||||||
|
url,
|
||||||
|
mockServer,
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
expect(res!.status).toBe(200);
|
||||||
|
expect((await res!.json()).count).toBeGreaterThan(0);
|
||||||
|
const { clientExists } = await import("../../db");
|
||||||
|
expect(clientExists(allowedId)).toBe(false);
|
||||||
|
expect(clientExists(deniedId)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("clients:reconnect gates", () => {
|
describe("clients:reconnect gates", () => {
|
||||||
test("operator (default) can reconnect", async () => {
|
test("operator (default) can reconnect", async () => {
|
||||||
const auth = await makeUserWithToken("operator");
|
const auth = await makeUserWithToken("operator");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
clientExists,
|
clientExists,
|
||||||
deleteClientRow,
|
deleteClientRow,
|
||||||
deleteOfflineClientRows,
|
deleteOfflineClientRows,
|
||||||
|
deleteOfflineClientRowsForScope,
|
||||||
getClientOnlineState,
|
getClientOnlineState,
|
||||||
getClientIp,
|
getClientIp,
|
||||||
isIpBanned,
|
isIpBanned,
|
||||||
@@ -150,7 +151,16 @@ export async function handleClientRoutes(
|
|||||||
if (url.pathname === "/api/clients/countries") {
|
if (url.pathname === "/api/clients/countries") {
|
||||||
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 });
|
||||||
return Response.json({ countries: listDistinctCountries() }, { headers: deps.CORS_HEADERS });
|
if (user.role === "admin") {
|
||||||
|
return Response.json({ countries: listDistinctCountries() }, { headers: deps.CORS_HEADERS });
|
||||||
|
}
|
||||||
|
const scope = getUserClientAccessScope(user.userId);
|
||||||
|
const allowedClientIds = scope === "allowlist" ? listUserClientRuleIdsByAccess(user.userId, "allow") : scope === "none" ? [] : undefined;
|
||||||
|
const deniedClientIds = scope === "denylist" ? listUserClientRuleIdsByAccess(user.userId, "deny") : undefined;
|
||||||
|
return Response.json(
|
||||||
|
{ countries: listDistinctCountries(allowedClientIds, deniedClientIds) },
|
||||||
|
{ headers: deps.CORS_HEADERS },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const logsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/logs$/);
|
const logsMatch = url.pathname.match(/^\/api\/clients\/(.+)\/logs$/);
|
||||||
@@ -331,7 +341,15 @@ export async function handleClientRoutes(
|
|||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
const count = deleteOfflineClientRows();
|
let count: number;
|
||||||
|
if (user.role === "admin") {
|
||||||
|
count = deleteOfflineClientRows();
|
||||||
|
} else {
|
||||||
|
const scope = getUserClientAccessScope(user.userId);
|
||||||
|
const allowedClientIds = scope === "allowlist" ? listUserClientRuleIdsByAccess(user.userId, "allow") : scope === "none" ? [] : undefined;
|
||||||
|
const deniedClientIds = scope === "denylist" ? listUserClientRuleIdsByAccess(user.userId, "deny") : undefined;
|
||||||
|
count = deleteOfflineClientRowsForScope(allowedClientIds, deniedClientIds);
|
||||||
|
}
|
||||||
notifyDashboardViewers();
|
notifyDashboardViewers();
|
||||||
const ip = server.requestIP(req)?.address || "unknown";
|
const ip = server.requestIP(req)?.address || "unknown";
|
||||||
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `wipe_offline_clients: removed ${count}`, success: true });
|
logAudit({ timestamp: Date.now(), username: user.username, ip, action: AuditAction.COMMAND, details: `wipe_offline_clients: removed ${count}`, success: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user