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>
This commit is contained in:
morlutoandgongfpp committed 2026-10-16 02:23:25 +08:00
1 parent fb51960d7d
commit 5fb6edc34b
3 files changed
+124 -4

No files matched your search

@@ -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 =
"<plist><dict><key>CFBundleExecutable</key><string>Missing</string></dict></plist>";
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 });
}
});
});
+17 -4
View File
@@ -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(
@@ -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"),
"<plist><dict><key>CFBundleExecutable</key><string>Escaping</string></dict></plist>",
);
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"),
"<plist><dict><key>CFBundleExecutable</key><string>Example</string></dict></plist>",
);
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", () => {