mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(electron): follow absolute symlinks that stay inside the framework (#3587)
* fix(electron): follow absolute symlinks that stay inside the framework An absolute symlink target is now passed to relativeInside(root, target), and a null result still ends the walk as an escape. When the target lies inside the root, resolved is cleared and the root-relative pieces are queued on pending, which replaces the path walked so far with that absolute target. Later hops stay under the hop limit and the existing lstat containment checks. Provider tests now cover an absolute Versions/Current that stays on in-framework version B and matches the relative bundle's resolved path and SHA-256, an absolute Current outside the framework, and an absolute in-framework link whose next hop leaves the framework; those last two still surface an escape error. Fixes #3380 * test(electron): cover absolute framework symlink cycles --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
2 files changed
+154
-2
No files matched your search
@@ -211,8 +211,17 @@ const resolveInside = async (
|
||||
hops += 1;
|
||||
if (hops > MAX_SYMLINK_HOPS) return { kind: "loop" };
|
||||
const target = await readlink(absolute);
|
||||
if (isAbsolute(target)) return { kind: "escapes" };
|
||||
pending.unshift(...target.split("/").filter((part) => part !== ""));
|
||||
// Lexical only: another alias of this root (/tmp vs /private/tmp)
|
||||
// is still an escape. Later hops stay lstat- and containment-checked.
|
||||
const absoluteTarget = isAbsolute(target);
|
||||
const relativeTarget = absoluteTarget
|
||||
? relativeInside(root, target)
|
||||
: target;
|
||||
if (relativeTarget === null) return { kind: "escapes" };
|
||||
if (absoluteTarget) resolved.length = 0;
|
||||
pending.unshift(
|
||||
...relativeTarget.split("/").filter((part) => part !== ""),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (metadata.isDirectory()) {
|
||||
|
||||
@@ -507,6 +507,149 @@ describe("direct Electron Framework binary selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("absolute Electron Framework symlinks", () => {
|
||||
const versionedFramework = async (prefix: string) => {
|
||||
const root = await createTestTempDirectory(prefix);
|
||||
const app = join(root, "Fixture.app");
|
||||
const framework = join(
|
||||
app,
|
||||
"Contents",
|
||||
"Frameworks",
|
||||
"Electron Framework.framework",
|
||||
);
|
||||
for (const [version, state] of [
|
||||
["A", 0x30],
|
||||
["B", 0x31],
|
||||
] as const) {
|
||||
await mkdir(join(framework, "Versions", version), { recursive: true });
|
||||
await writeFile(
|
||||
join(framework, "Versions", version, "Electron Framework"),
|
||||
thinFuseImage([state]).bytes,
|
||||
);
|
||||
}
|
||||
return { root, app, framework };
|
||||
};
|
||||
|
||||
const expectEscapes = async (path: string) => {
|
||||
const result = await provider.inspect({ path });
|
||||
expect(result.ok, path).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisInputError",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["path"],
|
||||
message: expect.stringContaining("escapes"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const expectHopLimit = async (path: string) => {
|
||||
const result = await provider.inspect({ path });
|
||||
expect(result.ok, path).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisInputError",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["path"],
|
||||
message: expect.stringContaining("hop limit exceeded"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
it("follows an absolute Versions/Current link that stays inside", async () => {
|
||||
const relative = await versionedFramework(
|
||||
"rea-electron-fuses-absolute-relative-",
|
||||
);
|
||||
const absolute = await versionedFramework(
|
||||
"rea-electron-fuses-absolute-inside-",
|
||||
);
|
||||
await symlink("B", join(relative.framework, "Versions", "Current"));
|
||||
await symlink(
|
||||
join(absolute.framework, "Versions", "B"),
|
||||
join(absolute.framework, "Versions", "Current"),
|
||||
);
|
||||
|
||||
const fromRelative = await inspect(relative.app);
|
||||
const fromAbsolute = await inspect(absolute.app);
|
||||
const binary = join(
|
||||
absolute.framework,
|
||||
"Versions",
|
||||
"B",
|
||||
"Electron Framework",
|
||||
);
|
||||
const selected = join(
|
||||
absolute.framework,
|
||||
"Versions",
|
||||
"Current",
|
||||
"Electron Framework",
|
||||
);
|
||||
const fromSelected = await inspect(selected);
|
||||
expect(fromRelative.artifact.resolved_path).toBe(
|
||||
join(relative.framework, "Versions", "B", "Electron Framework"),
|
||||
);
|
||||
expect(fromAbsolute.artifact.resolved_path).toBe(binary);
|
||||
expect(fromAbsolute.artifact.sha256).toBe(fromRelative.artifact.sha256);
|
||||
expect(fromAbsolute.slices[0]?.sentinels[0]?.fuses[0]?.state).toBe(
|
||||
"enabled",
|
||||
);
|
||||
expect(fromSelected.artifact.resolved_path).toBe(binary);
|
||||
expect(fromSelected.artifact.sha256).toBe(fromRelative.artifact.sha256);
|
||||
});
|
||||
|
||||
it("rejects an absolute Versions/Current link outside the framework", async () => {
|
||||
const { root, app, framework } = await versionedFramework(
|
||||
"rea-electron-fuses-absolute-outside-",
|
||||
);
|
||||
const outside = join(root, "outside");
|
||||
await mkdir(outside);
|
||||
await writeFile(
|
||||
join(outside, "Electron Framework"),
|
||||
thinFuseImage([0x31]).bytes,
|
||||
);
|
||||
await symlink(outside, join(framework, "Versions", "Current"));
|
||||
|
||||
await expectEscapes(app);
|
||||
});
|
||||
|
||||
it("rejects an absolute in-framework link that then hops outside", async () => {
|
||||
const { app, framework } = await versionedFramework(
|
||||
"rea-electron-fuses-absolute-hop-",
|
||||
);
|
||||
const outside = join(app, "Contents", "Other");
|
||||
await mkdir(outside);
|
||||
await writeFile(
|
||||
join(outside, "Electron Framework"),
|
||||
thinFuseImage([0x31]).bytes,
|
||||
);
|
||||
await symlink("../../../Other", join(framework, "Versions", "Gate"));
|
||||
await symlink(
|
||||
join(framework, "Versions", "Gate"),
|
||||
join(framework, "Versions", "Current"),
|
||||
);
|
||||
|
||||
await expectEscapes(app);
|
||||
await expectEscapes(
|
||||
join(framework, "Versions", "Current", "Electron Framework"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an absolute Current cycle instead of falling back to Versions/A", async () => {
|
||||
const { app, framework } = await versionedFramework(
|
||||
"rea-electron-fuses-absolute-cycle-",
|
||||
);
|
||||
const current = join(framework, "Versions", "Current");
|
||||
// A valid A binary exists, so this also proves the failed Current walk is
|
||||
// not mistaken for absence and silently replaced by the fallback.
|
||||
await symlink(current, current);
|
||||
|
||||
await expectHopLimit(app);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["Contents", "Frameworks"])(
|
||||
"rejects a redirected %s for every app selection form",
|
||||
async (segment) => {
|
||||
|
||||
Reference in new issue
Block a user