RBAC changes + fix rev console and process view in shellcode
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { generateToken } from "../../auth";
|
||||
import {
|
||||
ALL_FEATURES,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
setUserClientAccessScope,
|
||||
setUserFeaturePermission,
|
||||
} from "../../users";
|
||||
import { handleAuthRoutes } from "./auth-routes";
|
||||
|
||||
const PASSWORD = "Aa1!AuthRoutesFeaturePermsTest_2026";
|
||||
|
||||
const mockServer = {
|
||||
requestIP: () => ({ address: "127.0.0.1" }),
|
||||
};
|
||||
|
||||
async function createTokenFor(role: "admin" | "operator" | "viewer") {
|
||||
const username = `me_${role.slice(0, 3)}_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const created = await createUser(username, PASSWORD, role, "test");
|
||||
expect(created.success).toBe(true);
|
||||
expect(typeof created.userId).toBe("number");
|
||||
const user = getUserById(created.userId!);
|
||||
expect(user).not.toBeNull();
|
||||
const token = await generateToken(user!);
|
||||
return { userId: created.userId!, token, username, role };
|
||||
}
|
||||
|
||||
describe("/api/auth/me featurePermissions", () => {
|
||||
test("admin gets every feature allowed in /api/auth/me", async () => {
|
||||
const auth = await createTokenFor("admin");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/me");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(200);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.username).toBe(auth.username);
|
||||
expect(body.role).toBe("admin");
|
||||
expect(body.featurePermissions).toBeDefined();
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(body.featurePermissions[f]).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("operator gets every feature allowed by default in /api/auth/me", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/me");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.role).toBe("operator");
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(body.featurePermissions[f]).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("operator-denied features are reflected in /api/auth/me", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
setUserFeaturePermission(auth.userId, "webcam", false);
|
||||
setUserFeaturePermission(auth.userId, "file_browser", false);
|
||||
|
||||
const url = new URL("https://localhost/api/auth/me");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.featurePermissions.webcam).toBe(false);
|
||||
expect(body.featurePermissions.file_browser).toBe(false);
|
||||
expect(body.featurePermissions.console).toBe(true);
|
||||
expect(body.featurePermissions.hvnc).toBe(true);
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("viewer gets every feature denied in /api/auth/me", async () => {
|
||||
const auth = await createTokenFor("viewer");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/me");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.role).toBe("viewer");
|
||||
for (const f of ALL_FEATURES) {
|
||||
expect(body.featurePermissions[f]).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("/api/auth/me requires authentication", async () => {
|
||||
const url = new URL("https://localhost/api/auth/me");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, { method: "GET" }),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/auth/feature-check", () => {
|
||||
test("operator with allowed feature and accessible client returns allowed=true", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
setUserClientAccessScope(auth.userId, "all");
|
||||
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res!.status).toBe(200);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(true);
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("operator with revoked feature returns allowed=false with reason 'feature'", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
setUserClientAccessScope(auth.userId, "all");
|
||||
setUserFeaturePermission(auth.userId, "webcam", false);
|
||||
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=webcam&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res!.status).toBe(200);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.denied).toContain("feature");
|
||||
expect(body.denied).not.toContain("client");
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("operator without client access returns allowed=false with reason 'client'", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
// default client_scope=none
|
||||
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res!.status).toBe(200);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.denied).toContain("client");
|
||||
expect(body.denied).not.toContain("feature");
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("both feature and client denied returns both reasons", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
setUserFeaturePermission(auth.userId, "hvnc", false);
|
||||
// client_scope stays "none" => no access
|
||||
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=hvnc&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.denied).toContain("feature");
|
||||
expect(body.denied).toContain("client");
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("viewer is denied every feature", async () => {
|
||||
const auth = await createTokenFor("viewer");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.denied).toContain("feature");
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("admin is allowed every feature, even with no client_scope", async () => {
|
||||
const auth = await createTokenFor("admin");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=webcam&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(true);
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid feature name returns 400", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=not_a_feature&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res!.status).toBe(400);
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test("unauthenticated request returns 401", async () => {
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=console&clientId=client-x");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, { method: "GET" }),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(401);
|
||||
});
|
||||
|
||||
test("clientId is optional — feature-only check works without clientId", async () => {
|
||||
const auth = await createTokenFor("operator");
|
||||
try {
|
||||
setUserFeaturePermission(auth.userId, "webcam", false);
|
||||
|
||||
const url = new URL("https://localhost/api/auth/feature-check?feature=webcam");
|
||||
const res = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.denied).toEqual(["feature"]);
|
||||
} finally {
|
||||
deleteUser(auth.userId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
recordFailedAttempt,
|
||||
recordSuccessfulAttempt,
|
||||
} from "../../rateLimit";
|
||||
import { getUserById, canUserAccessClient, canUserAccessFeature, type FeatureName, ALL_FEATURES } from "../../users";
|
||||
import { getUserById, canUserAccessClient, canUserAccessFeature, getUserFeaturePermissions, type FeatureName, ALL_FEATURES } from "../../users";
|
||||
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
|
||||
|
||||
type RequestIpProvider = {
|
||||
@@ -340,6 +340,7 @@ export async function handleAuthRoutes(
|
||||
}
|
||||
|
||||
const dbUser = getUserById(user.userId);
|
||||
const featurePermissions = getUserFeaturePermissions(user.userId);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -349,6 +350,7 @@ export async function handleAuthRoutes(
|
||||
mustChangePassword: dbUser ? Boolean(dbUser.must_change_password) : false,
|
||||
canBuild: dbUser ? Boolean(dbUser.can_build) : false,
|
||||
telegramChatId: dbUser?.telegram_chat_id || "",
|
||||
featurePermissions,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -4,7 +4,13 @@ import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { generateToken } from "../../auth";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import { createUser, deleteUser, getUserById } from "../../users";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
setUserClientAccessScope,
|
||||
setUserFeaturePermission,
|
||||
} from "../../users";
|
||||
import { handleFileDownloadRoutes } from "./file-download-routes";
|
||||
|
||||
const PASSWORD = "Aa1!RouteUploadTestPass123";
|
||||
@@ -249,3 +255,315 @@ describe("file upload route flow", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function createOperatorToken(opts: { fileBrowserAllowed: boolean; clientScope?: "none" | "all" } = { fileBrowserAllowed: true, clientScope: "all" }) {
|
||||
const username = `op_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const created = await createUser(username, PASSWORD, "operator", "test");
|
||||
expect(created.success).toBe(true);
|
||||
expect(typeof created.userId).toBe("number");
|
||||
|
||||
const userId = created.userId!;
|
||||
setUserClientAccessScope(userId, opts.clientScope ?? "all");
|
||||
setUserFeaturePermission(userId, "file_browser", opts.fileBrowserAllowed);
|
||||
|
||||
const user = getUserById(userId);
|
||||
expect(user).not.toBeNull();
|
||||
|
||||
const token = await generateToken(user!);
|
||||
return { userId, token };
|
||||
}
|
||||
|
||||
describe("file route feature_browser enforcement", () => {
|
||||
test("upload request returns 403 when file_browser is denied", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: false });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const res = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\big.iso",
|
||||
fileName: "big.iso",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(403);
|
||||
expect(await res!.text()).toContain("feature access denied");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("download request returns 403 when file_browser is denied", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: false });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/download/request");
|
||||
const res = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\secret\\data.bin",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(403);
|
||||
expect(await res!.text()).toContain("feature access denied");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("client_access check still beats feature check when client access is missing", async () => {
|
||||
// If both are denied, client access fires first — guards against misordered checks.
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: false, clientScope: "none" });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const res = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\big.iso",
|
||||
fileName: "big.iso",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(403);
|
||||
expect(await res!.text()).toContain("client access denied");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("upload stage PUT returns 403 when file_browser is revoked after intent creation", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: true });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const requestRes = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\small.bin",
|
||||
fileName: "small.bin",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
expect(requestRes!.status).toBe(200);
|
||||
const requestJson = (await requestRes!.json()) as any;
|
||||
|
||||
// Revoke after the intent has been created.
|
||||
setUserFeaturePermission(auth.userId, "file_browser", false);
|
||||
|
||||
const stageUrl = new URL(`https://localhost${requestJson.uploadUrl}`);
|
||||
const stageRes = await handleFileDownloadRoutes(
|
||||
new Request(stageUrl, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
body: new TextEncoder().encode("hello"),
|
||||
}),
|
||||
stageUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(stageRes).not.toBeNull();
|
||||
expect(stageRes!.status).toBe(403);
|
||||
expect(await stageRes!.text()).toContain("feature access denied");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("download serve returns 403 when file_browser is revoked after intent creation", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: true });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/download/request");
|
||||
const requestRes = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\file.bin",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
expect(requestRes!.status).toBe(200);
|
||||
const requestJson = (await requestRes!.json()) as any;
|
||||
expect(typeof requestJson.downloadUrl).toBe("string");
|
||||
|
||||
// Revoke after the intent has been created.
|
||||
setUserFeaturePermission(auth.userId, "file_browser", false);
|
||||
|
||||
const downloadUrl = new URL(`https://localhost${requestJson.downloadUrl}`);
|
||||
const downloadRes = await handleFileDownloadRoutes(
|
||||
new Request(downloadUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
}),
|
||||
downloadUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(downloadRes).not.toBeNull();
|
||||
expect(downloadRes!.status).toBe(403);
|
||||
expect(await downloadRes!.text()).toContain("feature access denied");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("upload request succeeds for operator with file_browser allowed (positive control)", async () => {
|
||||
const auth = await createOperatorToken({ fileBrowserAllowed: true });
|
||||
const clientId = `client-${Date.now().toString(36)}`;
|
||||
const dataDir = await createTempDataDir();
|
||||
const deps = makeRouteDeps(dataDir);
|
||||
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const requestUrl = new URL("https://localhost/api/file/upload/request");
|
||||
const res = await handleFileDownloadRoutes(
|
||||
new Request(requestUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
path: "C:\\Games\\ok.bin",
|
||||
fileName: "ok.bin",
|
||||
}),
|
||||
}),
|
||||
requestUrl,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(200);
|
||||
const body = (await res!.json()) as any;
|
||||
expect(body.ok).toBe(true);
|
||||
expect(typeof body.uploadUrl).toBe("string");
|
||||
} finally {
|
||||
clientManager.deleteClient(clientId);
|
||||
deleteUser(auth.userId);
|
||||
await removeTempDataDir(dataDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { logger } from "../../logger";
|
||||
import { encodeMessage } from "../../protocol";
|
||||
import { getConfig } from "../../config";
|
||||
import { isAuthorizedAgentRequest } from "../agent-auth";
|
||||
import { canUserAccessClient } from "../../users";
|
||||
import { canUserAccessClient, canUserAccessFeature } from "../../users";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -160,6 +160,10 @@ async function serveDownloadById(
|
||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||
}
|
||||
|
||||
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
|
||||
return new Response("Forbidden: feature access denied", { status: 403 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http download request", {
|
||||
downloadId,
|
||||
userId: user.userId,
|
||||
@@ -363,6 +367,10 @@ export async function handleFileDownloadRoutes(
|
||||
return new Response("Forbidden: client access denied", { status: 403 });
|
||||
}
|
||||
|
||||
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
|
||||
return new Response("Forbidden: feature access denied", { status: 403 });
|
||||
}
|
||||
|
||||
logger.debug("[filebrowser] http upload request", {
|
||||
userId: user.userId,
|
||||
clientId,
|
||||
@@ -408,6 +416,10 @@ export async function handleFileDownloadRoutes(
|
||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||
}
|
||||
|
||||
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
|
||||
return new Response("Forbidden: feature access denied", { status: 403 });
|
||||
}
|
||||
|
||||
let uploadId = "";
|
||||
try {
|
||||
uploadId = decodeURIComponent(url.pathname.slice("/api/file/upload/".length));
|
||||
@@ -651,6 +663,10 @@ export async function handleFileDownloadRoutes(
|
||||
return new Response("Forbidden: client access denied", { status: 403 });
|
||||
}
|
||||
|
||||
if (!canUserAccessFeature(user.userId, user.role as any, "file_browser")) {
|
||||
return new Response("Forbidden: feature access denied", { status: 403 });
|
||||
}
|
||||
|
||||
const target = clientManager.getClient(clientId);
|
||||
if (!target) {
|
||||
return new Response("Client offline", { status: 404 });
|
||||
|
||||
Reference in New Issue
Block a user