From 1ea69035959fe86dfab921ff66a2ddcd4471a03f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:19:17 +0800 Subject: [PATCH] fix(ida): preserve path identity across symlink traversal --- src/ida/IdaSessionClient.test.ts | 81 +++++++++++++++++++++++++++++++- src/ida/IdaWorkspace.ts | 8 ++-- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 38a8dac783..3f2d32e71c 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -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", { diff --git a/src/ida/IdaWorkspace.ts b/src/ida/IdaWorkspace.ts index b8219421f7..eaab49f41c 100644 --- a/src/ida/IdaWorkspace.ts +++ b/src/ida/IdaWorkspace.ts @@ -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 => { 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.