fix(ida): preserve path identity across symlink traversal

This commit is contained in:
morluto
2026-10-27 13:19:17 +08:00
parent c29a93097a
commit 1ea6903595
2 changed files with 84 additions and 5 deletions
+79 -2
View File
@@ -1,5 +1,13 @@
import { readFile, readdir, rm } from "node:fs/promises";
import { sep } from "node:path";
import {
mkdir,
readFile,
readdir,
realpath,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { basename, dirname, join, sep } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import {
@@ -356,6 +364,75 @@ describe("attached IDA input identity and validation", () => {
await client.close();
});
it.skipIf(process.platform === "win32")(
"rejects a document whose symlink traversal names a different input",
async () => {
const { root, target, producer, client } = await fixture();
await mkdir(join(root, "foreign", "inner"), { recursive: true });
await writeFile(
join(root, "foreign", basename(target.path)),
"foreign input",
);
await symlink(join(root, "foreign", "inner"), join(root, "link"));
const document = `${root}/link/../${basename(target.path)}`;
expect(await realpath(document)).not.toBe(await realpath(target.path));
const result = await client.execute("list_procedures", { document });
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
expect(producer.connects).toBe(0);
await client.close();
},
);
it.skipIf(process.platform === "win32")(
"refuses an opened worker whose input only matches after lexical parent collapse",
async () => {
const { root, producer, client } = await fixture("headless");
await mkdir(join(root, "foreign", "inner"), { recursive: true });
let privateInput = "";
producer.beforeCall = async (name, args) => {
if (name !== "idb_open") return;
privateInput = z.string().parse(args.input_path);
await writeFile(
join(root, "foreign", basename(privateInput)),
"foreign input",
);
await symlink(
join(root, "foreign", "inner"),
join(dirname(privateInput), "link"),
);
};
producer.overrideCall = (name, args) => {
if (name !== "idb_open") return undefined;
producer.database = z.string().parse(args.preferred_session_id);
producer.inputPath = `${dirname(privateInput)}/link/../${basename(privateInput)}`;
return {
success: true,
session: {
session_id: producer.database,
input_path: producer.inputPath,
},
};
};
const result = await client.execute("list_procedures", {});
expect(await realpath(producer.inputPath)).not.toBe(
await realpath(privateInput),
);
expect(result.ok).toBe(false);
expect(producer.calls.some(({ name }) => name === "list_funcs")).toBe(
false,
);
producer.inputPath = privateInput;
await client.close();
},
);
it("rejects an unrelated document before connecting or opening a provider", async () => {
const { producer, client } = await fixture();
const result = await client.execute("list_procedures", {
+5 -3
View File
@@ -2,7 +2,8 @@ import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { open, realpath, stat, type FileHandle } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join, normalize } from "node:path";
import { basename, isAbsolute, join, normalize } from "node:path";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
@@ -21,11 +22,12 @@ export const idaFileDigest = async (path: string): Promise<string> => {
return hash.digest("hex");
};
/** Compare host-local paths without confusing Windows paths with POSIX coordinates. */
/** Compare host-local spellings without collapsing POSIX symlink/.. traversal. */
export const sameIdaHostPath = (left: string, right: string): boolean =>
process.platform === "win32"
? normalize(left).toLowerCase() === normalize(right).toLowerCase()
: normalize(left) === normalize(right);
: isAbsolute(left) === isAbsolute(right) &&
absoluteCallerPath(left) === absoluteCallerPath(right);
/** Admit the selected lookup before canonicalizing it for readiness and allocation.
* Native realpath can collapse file/.. without rejecting the non-directory.