From 5fb6edc34b57e0ae45bf95a5496ddcd6e23f59fd Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:17:18 +0800 Subject: [PATCH] fix(target): confine app program directories to their canonical bundle Validate the canonical programs directory before resolving its executable, preserving containment failures even when the external child is missing. Retain in-bundle directory and bundle aliases. Co-authored-by: gongfpp <22683417+gongfpp@users.noreply.github.com> --- src/application/AppBundleExecutable.test.ts | 43 +++++++++++++ src/application/AppBundleExecutable.ts | 21 ++++-- .../boundary/filesystem/binaryTarget.test.ts | 64 +++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src/application/AppBundleExecutable.test.ts b/src/application/AppBundleExecutable.test.ts index 942a001122..d32e6bdd2b 100644 --- a/src/application/AppBundleExecutable.test.ts +++ b/src/application/AppBundleExecutable.test.ts @@ -32,3 +32,46 @@ describe("app bundle Info.plist magic", () => { } }); }); + +describe("app bundle program directory containment", () => { + it("rejects an escaping programs directory before resolving its child", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-app-programs-")); + try { + const bundle = join(root, "Fixture.app"); + const programs = join(bundle, "Contents", "MacOS"); + const executable = join(programs, "Missing"); + const canonicalBundle = join(root, "Canonical.app"); + const outsidePrograms = join(root, "outside"); + const plist = + "CFBundleExecutableMissing"; + await mkdir(join(bundle, "Contents"), { recursive: true }); + await writeFile(join(bundle, "Contents", "Info.plist"), plist); + let childResolved = false; + + const result = await resolveAppBundleExecutable(bundle, { + readFile: async () => Buffer.from(plist), + realpath: async (path) => { + if (path === bundle) return canonicalBundle; + if (path === programs) return outsidePrograms; + if (path === executable) { + childResolved = true; + throw Object.assign(new Error("missing executable"), { + code: "ENOENT", + }); + } + return path; + }, + }); + + expect(result).toMatchObject({ + ok: false, + error: { + reason: "app program directory leaves Contents/MacOS", + }, + }); + expect(childResolved).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index 7ab79a3cf4..eff93d5dae 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -219,11 +219,24 @@ const resolveLayoutExecutable = async ( ); const programs = join(bundle, ...layout.programs); const executable = join(programs, name); + let canonicalBundle: string; try { - const [canonicalPrograms, canonicalExecutable] = await Promise.all([ - fileSystem.realpath(programs), - fileSystem.realpath(executable), - ]); + canonicalBundle = await fileSystem.realpath(bundle); + } catch (cause: unknown) { + return err( + new BinaryTargetError(bundle, errorReason(cause, bundle), { cause }), + ); + } + try { + const canonicalPrograms = await fileSystem.realpath(programs); + if (!isPathWithinRoot(canonicalBundle, canonicalPrograms)) + return err( + new BinaryTargetError( + bundle, + `app program directory leaves ${layout.programsLabel}`, + ), + ); + const canonicalExecutable = await fileSystem.realpath(executable); if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable)) return err( new BinaryTargetError( diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index ad3d663949..3a8263c5f1 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -429,7 +429,9 @@ describe("binary target I/O: filesystem and app bundle target resolution", () => ).ok, ).toBe(false); }); +}); +describe("app bundle canonical containment", () => { it.skipIf(process.platform === "win32")( "rejects an app program symlink that leaves the bundle", async () => { @@ -455,6 +457,68 @@ describe("binary target I/O: filesystem and app bundle target resolution", () => ).toBe(false); }, ); + + it.skipIf(process.platform === "win32")( + "rejects a Contents/MacOS directory symlink that leaves the bundle", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Escaping.app"); + const contents = join(app, "Contents"); + const programs = join(contents, "MacOS"); + const outside = join(directory, "outside"); + await mkdir(contents, { recursive: true }); + await mkdir(outside); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableEscaping", + ); + await writeFile( + join(outside, "Escaping"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await symlink(outside, programs, "dir"); + + const result = await parseBinaryTarget(app, { + cwd: directory, + hostArchitecture: "arm64", + }); + expect(result).toMatchObject({ + ok: false, + error: { + path: app, + reason: "app program directory leaves Contents/MacOS", + }, + }); + }, + ); + + it.skipIf(process.platform === "win32")( + "allows bundle and program directory aliases within the canonical bundle", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Canonical.app"); + const alias = join(directory, "Alias.app"); + const contents = join(app, "Contents"); + const programs = join(contents, "Programs"); + const executable = join(programs, "Example"); + await mkdir(programs, { recursive: true }); + await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c)); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableExample", + ); + await symlink(programs, join(contents, "MacOS"), "dir"); + await symlink(app, alias, "dir"); + const result = await parseBinaryTarget(alias, { + cwd: directory, + hostArchitecture: "arm64", + }); + expect(result).toMatchObject({ + ok: true, + value: { path: await realpath(executable), format: "mach-o" }, + }); + }, + ); }); describe("binary target I/O: explicit kinds and executable headers", () => {