fix: restrict godmode app launches to godmode callers

`ExecService.launchApp`, reachable from `puter.ui.launchApp`, launched any
named app with no check on the target. Gate it: a godmode target may only
be launched by a godmode caller. Desktop launches (tile, double-click,
URL) call `launch_app` directly and are unaffected; `connectToInstance`
already had its own allowlist.
This commit is contained in:
Juan Castro
2026-09-22 09:12:25 -04:00
parent 4de1d1fb39
commit 1bc8cb964e
2 changed files with 144 additions and 0 deletions
+20
View File
@@ -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();
+124
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});