diff --git a/src/gui/src/services/ExecService.js b/src/gui/src/services/ExecService.js index 9b9b111f2..b71950973 100644 --- a/src/gui/src/services/ExecService.js +++ b/src/gui/src/services/ExecService.js @@ -21,6 +21,17 @@ import { PROCESS_IPC_ATTACHED, Service } from '../definitions.js'; import launch_app from '../helpers/launchApp.js'; import { expand_home_path } from '../helpers/expandHomePath.js'; +// The /apps endpoint serializes `godmode` as a boolean; older cached shapes used 0/1. +const is_godmode = (app_info) => + !! app_info && (app_info.godmode === true || app_info.godmode === 1); + +// `window.get_apps(name)` returns the app object for a single name, or [] when absent. +const resolve_app = async (name) => { + if ( ! name ) return null; + const info = await window.get_apps(name); + return info && ! Array.isArray(info) ? info : null; +}; + export class ExecService extends Service { static description = ` Manages instances of apps on the Puter desktop. @@ -53,6 +64,15 @@ export class ExecService extends Service { const app = ipc_context?.caller?.app; const process = ipc_context?.caller?.process; + // Only a godmode app may launch another godmode app. + const target_app_info = await resolve_app(app_name); + if ( is_godmode(target_app_info) ) { + const caller_app_info = await resolve_app(process?.name); + if ( ! is_godmode(caller_app_info) ) { + throw new Error('Launching this app is not allowed.'); + } + } + // This mechanism will be replated with xdrpc soon const child_instance_id = window.uuidv4(); diff --git a/src/gui/src/services/ExecService.test.js b/src/gui/src/services/ExecService.test.js new file mode 100644 index 000000000..67f57be13 --- /dev/null +++ b/src/gui/src/services/ExecService.test.js @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +// `definitions.js` reads the class registry the bundle installs at boot. +globalThis.def = globalThis.def ?? ((cls) => cls); +globalThis.use = globalThis.use ?? (() => ({})); + +// A full launch pulls in UIWindow and the desktop; the gate under test runs +// before any of it, so stand the launcher in for a spy. +const launchSpy = vi.fn(async () => ({ launchResult: { launched: true } })); +vi.mock('../helpers/launchApp.js', () => ({ default: (...a) => launchSpy(...a) })); +vi.mock('../helpers/expandHomePath.js', () => ({ expand_home_path: (p) => p })); + +let ExecService; + +beforeAll(async () => { + ({ ExecService } = await import('./ExecService.js')); +}); + +// Registry of apps by name, read by the mocked `window.get_apps`. +let apps; + +const makeService = async () => { + const svc = new ExecService(); + svc.param_providers = svc.param_providers ?? []; + const ipc = { + register_ipc_handler: () => {}, + add_connection: () => ({ forward: { uuid: 'fwd' }, backward: { uuid: 'bwd' } }), + }; + await svc.init({ services: { get: (name) => (name === 'ipc' ? ipc : {}) } }); + return svc; +}; + +const callerProcess = (name) => ({ + name, + uuid: `proc-${name}`, + references: { iframe: null }, +}); + +beforeEach(() => { + launchSpy.mockClear(); + apps = new Map(); + globalThis.window = globalThis.window ?? {}; + let n = 0; + window.uuidv4 = () => `uuid-${++n}`; + window.get_apps = async (name) => apps.get(name) ?? []; + globalThis.puter = { logger: { fields: () => ({ warn () {} }) } }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('ExecService.launchApp godmode gate', () => { + it('refuses an ordinary app launching a godmode target', async () => { + const svc = await makeService(); + apps.set('privileged', { name: 'privileged', godmode: true }); + apps.set('ordinary', { name: 'ordinary', godmode: false }); + + await expect( + svc.launchApp( + { app_name: 'privileged', args: {} }, + { ipc_context: { caller: { app: {}, process: callerProcess('ordinary') } } }, + ), + ).rejects.toThrow(/not allowed/); + expect(launchSpy).not.toHaveBeenCalled(); + }); + + // 0/1 is the older serialized shape; it must gate the same as a boolean. + it('gates a godmode target expressed as 1 the same way', async () => { + const svc = await makeService(); + apps.set('privileged', { name: 'privileged', godmode: 1 }); + apps.set('ordinary', { name: 'ordinary', godmode: 0 }); + + await expect( + svc.launchApp( + { app_name: 'privileged' }, + { ipc_context: { caller: { app: {}, process: callerProcess('ordinary') } } }, + ), + ).rejects.toThrow(/not allowed/); + }); + + it('lets a godmode caller launch a godmode target', async () => { + const svc = await makeService(); + apps.set('privileged', { name: 'privileged', godmode: true }); + apps.set('privileged-caller', { name: 'privileged-caller', godmode: true }); + + await svc.launchApp( + { app_name: 'privileged' }, + { ipc_context: { caller: { app: {}, process: callerProcess('privileged-caller') } } }, + ); + expect(launchSpy).toHaveBeenCalledTimes(1); + }); + + it('does not gate an ordinary target', async () => { + const svc = await makeService(); + apps.set('editor', { name: 'editor', godmode: false }); + apps.set('ordinary', { name: 'ordinary', godmode: false }); + + await svc.launchApp( + { app_name: 'editor' }, + { ipc_context: { caller: { app: {}, process: callerProcess('ordinary') } } }, + ); + expect(launchSpy).toHaveBeenCalledTimes(1); + }); +});