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", () => {