fix(security): scope managed path opening
This commit is contained in:
1 parent
bde35c3518
commit
66e24b1060
9 files changed
+66
-30
No files matched your search
@@ -165,7 +165,7 @@ export function buildSettingsMethods(
|
||||
return purged;
|
||||
},
|
||||
|
||||
canOpenPath(targetPath: string) {
|
||||
canOpenPath(targetPath: string, companyId: string) {
|
||||
if (!targetPath || !existsSync(targetPath)) {
|
||||
return false;
|
||||
}
|
||||
@@ -184,15 +184,25 @@ export function buildSettingsMethods(
|
||||
if (!targetStats.isDirectory() && forbiddenExecutableExtensions.has(targetExtension)) {
|
||||
return false;
|
||||
}
|
||||
if (managedRoots.some((rootPath) => isWithinOrEqual(resolvedTarget, safeRealpath(rootPath)))) {
|
||||
return true;
|
||||
}
|
||||
const workspaces = readAll<{ localPath: string }>("select local_path as localPath from workspaces");
|
||||
const workspaces = readAll<{ localPath: string }>(
|
||||
"select local_path as localPath from workspaces where company_id = ?",
|
||||
companyId,
|
||||
);
|
||||
if (workspaces.some((workspace) => isWithinOrEqual(resolvedTarget, safeRealpath(workspace.localPath)))) {
|
||||
return true;
|
||||
}
|
||||
const runLogs = readAll<{ logPath: string | null }>("select log_path as logPath from runs where log_path is not null");
|
||||
return runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false);
|
||||
const runLogs = readAll<{ logPath: string | null }>(
|
||||
"select log_path as logPath from runs where company_id = ? and log_path is not null",
|
||||
companyId,
|
||||
);
|
||||
if (runLogs.some((run) => run.logPath ? safeRealpath(run.logPath) === resolvedTarget : false)) {
|
||||
return true;
|
||||
}
|
||||
const browserScreenshots = readAll<{ screenshotPath: string | null }>(
|
||||
"select screenshot_path as screenshotPath from browser_actions where company_id = ? and screenshot_path is not null",
|
||||
companyId,
|
||||
);
|
||||
return browserScreenshots.some((action) => action.screenshotPath ? safeRealpath(action.screenshotPath) === resolvedTarget : false);
|
||||
},
|
||||
|
||||
hasCompany(companyId: string) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
connectorInputSchema,
|
||||
connectorModelPoolQuerySchema,
|
||||
entityGetterSchema,
|
||||
managedPathInputSchema,
|
||||
metricsQuerySchema,
|
||||
settingsSchema,
|
||||
} from "@shared/contracts";
|
||||
@@ -57,6 +58,21 @@ export function registerCoreReadHandlers({
|
||||
| "listConnectorModels"
|
||||
| "logger"
|
||||
>) {
|
||||
function requireCurrentCompanyScope(requestedCompanyId: string) {
|
||||
const currentCompanyId = db.core.settings.getCurrentCompanyId();
|
||||
if (!currentCompanyId) {
|
||||
return { ok: false as const, error: fail("COMPANY_NOT_SELECTED", "Select a company before opening a managed path.") };
|
||||
}
|
||||
const companyError = ensureCompanyExists(currentCompanyId);
|
||||
if (companyError) {
|
||||
return { ok: false as const, error: companyError };
|
||||
}
|
||||
if (requestedCompanyId !== currentCompanyId) {
|
||||
return { ok: false as const, error: fail("INVALID_COMPANY_REFERENCE", "The requested company must match the current company.") };
|
||||
}
|
||||
return { ok: true as const, companyId: currentCompanyId };
|
||||
}
|
||||
|
||||
registerHandle(IPC_CHANNELS.loadSnapshot, () => ok(db.projection.snapshot.listRendererSnapshot()));
|
||||
|
||||
registerHandle(IPC_CHANNELS.pickDirectory, async () => {
|
||||
@@ -66,14 +82,17 @@ export function registerCoreReadHandlers({
|
||||
return ok(result.canceled ? null : (result.filePaths[0] ?? null));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.openPath, async (_event, targetPath) => {
|
||||
if (typeof targetPath !== "string" || targetPath.length === 0) {
|
||||
return fail("INVALID_PATH", "A valid path is required.");
|
||||
registerHandle(IPC_CHANNELS.openPath, async (_event, payload) => {
|
||||
const parsed = managedPathInputSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return fail("INVALID_PAYLOAD", "openPath requires { companyId, path }.");
|
||||
}
|
||||
if (!db.core.settings.canOpenPath(targetPath)) {
|
||||
const currentScope = requireCurrentCompanyScope(parsed.data.companyId);
|
||||
if (!currentScope.ok) return currentScope.error;
|
||||
if (!db.core.settings.canOpenPath(parsed.data.path, currentScope.companyId)) {
|
||||
return fail("OPEN_PATH_FORBIDDEN", "The requested path is outside the managed desktop workspace.");
|
||||
}
|
||||
const error = await shell.openPath(targetPath);
|
||||
const error = await shell.openPath(parsed.data.path);
|
||||
return error ? fail("OPEN_PATH_FAILED", error) : ok(true);
|
||||
});
|
||||
|
||||
@@ -91,9 +110,9 @@ export function registerCoreReadHandlers({
|
||||
registerHandle(IPC_CHANNELS.openWorkProduct, async (_event, payload) => {
|
||||
const parsed = entityGetterSchema.safeParse(payload);
|
||||
if (!parsed.success) return fail("INVALID_PAYLOAD", "openWorkProduct requires { companyId, id }.");
|
||||
const companyError = ensureCompanyExists(parsed.data.companyId);
|
||||
if (companyError) return companyError;
|
||||
const target = db.core.documents.resolveWorkProductTarget(parsed.data.id, parsed.data.companyId);
|
||||
const currentScope = requireCurrentCompanyScope(parsed.data.companyId);
|
||||
if (!currentScope.ok) return currentScope.error;
|
||||
const target = db.core.documents.resolveWorkProductTarget(parsed.data.id, currentScope.companyId);
|
||||
if (target.kind === "error") {
|
||||
const codeByReason = {
|
||||
not_found: "WORK_PRODUCT_NOT_FOUND",
|
||||
@@ -104,7 +123,7 @@ export function registerCoreReadHandlers({
|
||||
path_missing: "WORK_PRODUCT_PATH_MISSING",
|
||||
} as const;
|
||||
if (target.reason === "path_missing") {
|
||||
db.core.documents.updateWorkProduct(parsed.data.id, parsed.data.companyId, { status: "archived" });
|
||||
db.core.documents.updateWorkProduct(parsed.data.id, currentScope.companyId, { status: "archived" });
|
||||
publishDomainChanged();
|
||||
}
|
||||
return fail(codeByReason[target.reason], target.detail);
|
||||
@@ -112,7 +131,7 @@ export function registerCoreReadHandlers({
|
||||
if (target.kind === "url") {
|
||||
return openExternalUrlWithTrustPolicy(target.value);
|
||||
}
|
||||
if (!db.core.settings.canOpenPath(target.value)) {
|
||||
if (!db.core.settings.canOpenPath(target.value, currentScope.companyId)) {
|
||||
return fail("OPEN_PATH_FORBIDDEN", "The requested path is outside the managed desktop workspace.");
|
||||
}
|
||||
const error = await shell.openPath(target.value);
|
||||
|
||||
@@ -135,7 +135,12 @@ export function useCompanyController() {
|
||||
|
||||
const openPathWithFeedback = useCallback(async (path: string) => {
|
||||
const store = useAppStore.getState();
|
||||
try { unwrap(await window.agentCompany.openPath(path)); store.setError(null); }
|
||||
const companyId = store.snapshot?.currentCompanyId;
|
||||
if (!companyId) {
|
||||
store.setError("Select a company before opening a managed path.");
|
||||
return;
|
||||
}
|
||||
try { unwrap(await window.agentCompany.openPath({ companyId, path })); store.setError(null); }
|
||||
catch (err) { store.setError(describeDesktopError(err)); }
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -159,12 +159,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
||||
}
|
||||
}}
|
||||
onOpenPath={async (path) => {
|
||||
try {
|
||||
unwrap(await window.agentCompany.openPath(path));
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(describeDesktopError(error));
|
||||
}
|
||||
await openPathWithFeedback(path);
|
||||
}}
|
||||
onCancelAction={async (actionId) => {
|
||||
if (!snapshot?.currentCompanyId) return;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { z } from "zod";
|
||||
import type { CompanyMetrics, OnboardingBootstrapResult, ProfileSnapshot, StandupReport } from "../../types";
|
||||
import type { bootstrapOnboardingSchema, companyInputSchema, connectorDisabledModelsSchema, connectorInputSchema, settingsSchema } from "../schemas-company";
|
||||
import type { bootstrapOnboardingSchema, companyInputSchema, connectorDisabledModelsSchema, connectorInputSchema, managedPathInputSchema, settingsSchema } from "../schemas-company";
|
||||
import type { entityGetterSchema, metricsQuerySchema } from "../schemas-badges";
|
||||
import type { DesktopResult } from "./common";
|
||||
|
||||
export type CompaniesApi = {
|
||||
loadSnapshot: () => Promise<DesktopResult<ProfileSnapshot>>;
|
||||
pickDirectory: () => Promise<DesktopResult<string | null>>;
|
||||
openPath: (targetPath: string) => Promise<DesktopResult<boolean>>;
|
||||
openPath: (input: z.infer<typeof managedPathInputSchema>) => Promise<DesktopResult<boolean>>;
|
||||
openWorkProduct: (input: z.infer<typeof entityGetterSchema>) => Promise<DesktopResult<boolean>>;
|
||||
saveCompany: (input: z.infer<typeof companyInputSchema>) => Promise<DesktopResult<string>>;
|
||||
setCurrentCompany: (companyId: string) => Promise<DesktopResult<boolean>>;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
companyInputSchema,
|
||||
connectorDisabledModelsSchema,
|
||||
connectorInputSchema,
|
||||
managedPathInputSchema,
|
||||
socialAccountInputSchema,
|
||||
settingsSchema,
|
||||
workspaceInputSchema,
|
||||
@@ -213,10 +214,8 @@ export const PRELOAD_FORWARDERS = [
|
||||
export type PreloadForwardedMethod = (typeof PRELOAD_FORWARDERS)[number]["api"];
|
||||
export type PreloadForwardedApi = Pick<DesktopApi, PreloadForwardedMethod>;
|
||||
|
||||
const managedPathSchema = z.string().min(1);
|
||||
|
||||
export const PRELOAD_INPUT_SCHEMAS: Partial<Record<PreloadForwardedMethod, z.ZodType<unknown>>> = {
|
||||
openPath: managedPathSchema,
|
||||
openPath: managedPathInputSchema,
|
||||
openWorkProduct: entityGetterSchema,
|
||||
saveCompany: companyInputSchema,
|
||||
setCurrentCompany: companyIdSchema,
|
||||
|
||||
@@ -3,6 +3,11 @@ import { optionalId } from "./helpers";
|
||||
|
||||
export const companyIdSchema = z.string().uuid();
|
||||
|
||||
export const managedPathInputSchema = z.object({
|
||||
companyId: companyIdSchema,
|
||||
path: z.string().min(1),
|
||||
});
|
||||
|
||||
export const companyInputSchema = z.object({
|
||||
id: optionalId,
|
||||
name: z.string().min(2).max(80),
|
||||
|
||||
@@ -33,6 +33,7 @@ function registerOpenWorkProductHandler(url: string) {
|
||||
},
|
||||
settings: {
|
||||
canOpenPath: vi.fn(() => true),
|
||||
getCurrentCompanyId: vi.fn(() => companyId),
|
||||
},
|
||||
},
|
||||
projection: {
|
||||
|
||||
@@ -105,7 +105,9 @@ describe("preload forwarders", () => {
|
||||
expect(objectArg).toBeDefined();
|
||||
|
||||
expect(mapPreloadInvokeArgs(noArgs!.payload, [])).toEqual([]);
|
||||
expect(mapPreloadInvokeArgs(singleArg!.payload, ["/tmp/output"])).toEqual(["/tmp/output"]);
|
||||
expect(mapPreloadInvokeArgs(singleArg!.payload, [{ companyId: "2f88b7d1-dafb-42f1-ac75-34fc2de3b0f4", path: "/tmp/output" }])).toEqual([
|
||||
{ companyId: "2f88b7d1-dafb-42f1-ac75-34fc2de3b0f4", path: "/tmp/output" },
|
||||
]);
|
||||
expect(mapPreloadInvokeArgs(objectArg!.payload, ["company-1", "/skills/local"])).toEqual([
|
||||
{ companyId: "company-1", source: "/skills/local" },
|
||||
]);
|
||||
|
||||
Reference in new issue
Block a user