diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 5509fdd0e..b066e50b7 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -8,7 +8,7 @@ import { inheritLoginShellEnv } from "./login-shell-env.js"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { app, @@ -40,6 +40,7 @@ import { buildStandardContextMenuItems, } from "./window/window-manager.js"; import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js"; +import { configureLinuxSandbox } from "./system/linux-sandbox.js"; import { registerDialogHandlers } from "./features/dialogs.js"; import { registerNotificationHandlers, @@ -309,12 +310,13 @@ if (forcedUserDataDir) { } } -// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID -// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the -// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code. -if (process.platform === "linux" && process.env.APPIMAGE) { - app.commandLine.appendSwitch("no-sandbox"); -} +configureLinuxSandbox({ + platform: process.platform, + resourcesPath: process.resourcesPath, + statSandbox: (sandboxPath) => statSync(sandboxPath), + disableSandbox: () => app.commandLine.appendSwitch("no-sandbox"), + reportInspectionError: (error) => log.error("[linux-sandbox] failed to inspect helper", error), +}); // Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging // rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). diff --git a/packages/desktop/src/system/linux-sandbox.test.ts b/packages/desktop/src/system/linux-sandbox.test.ts new file mode 100644 index 000000000..988ac5745 --- /dev/null +++ b/packages/desktop/src/system/linux-sandbox.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { configureLinuxSandbox } from "./linux-sandbox"; + +interface SandboxMetadata { + mode: number; + uid: number; +} + +function configureWithSandbox( + sandbox: SandboxMetadata | Error, + platform: NodeJS.Platform = "linux", +) { + const disabledSwitches: string[] = []; + const inspectionErrors: unknown[] = []; + + configureLinuxSandbox({ + platform, + resourcesPath: "/opt/Paseo/resources", + statSandbox: () => { + if (sandbox instanceof Error) { + throw sandbox; + } + return sandbox; + }, + disableSandbox: () => disabledSwitches.push("no-sandbox"), + reportInspectionError: (error) => inspectionErrors.push(error), + }); + + return { disabledSwitches, inspectionErrors }; +} + +function createFileSystemError(code: string): NodeJS.ErrnoException { + const error: NodeJS.ErrnoException = new Error(code); + error.code = code; + return error; +} + +describe("configureLinuxSandbox", () => { + it("disables the sandbox when an AppImage mount strips SUID", () => { + expect(configureWithSandbox({ uid: 1000, mode: 0o755 })).toEqual({ + disabledSwitches: ["no-sandbox"], + inspectionErrors: [], + }); + }); + + it("keeps the sandbox for a root-owned 4755 helper", () => { + expect(configureWithSandbox({ uid: 0, mode: 0o4755 })).toEqual({ + disabledSwitches: [], + inspectionErrors: [], + }); + }); + + it("disables the sandbox when a SUID helper is not root-owned", () => { + expect(configureWithSandbox({ uid: 1000, mode: 0o4755 })).toEqual({ + disabledSwitches: ["no-sandbox"], + inspectionErrors: [], + }); + }); + + it("disables the sandbox when the helper is missing", () => { + expect(configureWithSandbox(createFileSystemError("ENOENT"))).toEqual({ + disabledSwitches: ["no-sandbox"], + inspectionErrors: [], + }); + }); + + it("keeps the sandbox and reports unexpected inspection failures", () => { + const permissionError = createFileSystemError("EACCES"); + + expect(configureWithSandbox(permissionError)).toEqual({ + disabledSwitches: [], + inspectionErrors: [permissionError], + }); + }); + + it("does not inspect or configure the sandbox outside Linux", () => { + expect(configureWithSandbox(createFileSystemError("EACCES"), "darwin")).toEqual({ + disabledSwitches: [], + inspectionErrors: [], + }); + }); +}); diff --git a/packages/desktop/src/system/linux-sandbox.ts b/packages/desktop/src/system/linux-sandbox.ts new file mode 100644 index 000000000..724195d4e --- /dev/null +++ b/packages/desktop/src/system/linux-sandbox.ts @@ -0,0 +1,45 @@ +import path from "node:path"; + +const REQUIRED_SANDBOX_MODE = 0o4755; +const PERMISSION_BITS = 0o7777; + +interface SandboxMetadata { + mode: number; + uid: number; +} + +interface LinuxSandboxConfiguration { + platform: NodeJS.Platform; + resourcesPath: string; + statSandbox: (sandboxPath: string) => SandboxMetadata; + disableSandbox: () => void; + reportInspectionError: (error: unknown) => void; +} + +function isMissingSandbox(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +export function configureLinuxSandbox(input: LinuxSandboxConfiguration): void { + if (input.platform !== "linux") { + return; + } + + try { + const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox"); + const sandbox = input.statSandbox(sandboxPath); + const hasUsableSandbox = + sandbox.uid === 0 && (sandbox.mode & PERMISSION_BITS) === REQUIRED_SANDBOX_MODE; + + if (!hasUsableSandbox) { + input.disableSandbox(); + } + } catch (error) { + if (isMissingSandbox(error)) { + input.disableSandbox(); + return; + } + + input.reportInspectionError(error); + } +}