From 65ef45bde6c75cc8073ff29e269e9647a27f7a2c Mon Sep 17 00:00:00 2001 From: Jax Liu Date: Fri, 4 Sep 2026 21:55:11 +0800 Subject: [PATCH] fix(genbi): secure context loader installer paths --- apps/context-loader/scripts/installer.mjs | 151 ++++++++++++++++---- apps/context-loader/test/installer.test.mjs | 54 +++++-- 2 files changed, 170 insertions(+), 35 deletions(-) diff --git a/apps/context-loader/scripts/installer.mjs b/apps/context-loader/scripts/installer.mjs index 6a0def74d..90e858af8 100644 --- a/apps/context-loader/scripts/installer.mjs +++ b/apps/context-loader/scripts/installer.mjs @@ -1,24 +1,37 @@ import { createHash } from "node:crypto"; -import { access, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +import { lstatSync } from "node:fs"; +import { chmod, lstat, mkdir, open, readFile, realpath, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; +import { promisify } from "node:util"; import { gunzipSync } from "node:zlib"; -import { CANONICAL_BINARY, ContextLoaderPackageError, isSha256, readJson, sha256File, targetFor } from "../lib/verified.mjs"; +import { CANONICAL_BINARY, ContextLoaderPackageError, isSha256, sha256File, targetFor } from "../lib/verified.mjs"; + +const execFile = promisify(execFileCallback); +const SOURCE_ORIGINS = new Set([ + "git@github.com:Canner/WrenAI.git", + "https://github.com/Canner/WrenAI.git", + "ssh://git@github.com/Canner/WrenAI.git", +]); /** Downloads, verifies, and atomically installs the exact manifest row for this package. */ export async function installContextLoader({ packageRoot, fetchImpl = fetch, platform = process.platform, arch = process.arch } = {}) { if (typeof packageRoot !== "string") throw new TypeError("packageRoot is required"); - const packageJson = readJson(path.join(packageRoot, "package.json"), "package.json"); - const manifest = readJson(path.join(packageRoot, "artifacts.json"), "artifacts.json"); + const root = await physicalPackageRoot(packageRoot); + const packageJson = await readOwnedJson(root, "package.json", "package.json"); + const manifest = await readOwnedJson(root, "artifacts.json", "artifacts.json"); const target = targetFor(platform, arch); const artifact = manifest.artifacts?.[target]; validateManifest(packageJson, manifest, artifact, target); - const binary = path.join(packageRoot, CANONICAL_BINARY); - const statePath = path.join(packageRoot, "install-state.json"); - const reusable = await existingVerified(binary, statePath, packageJson.version, target, artifact.binarySha256); - if (reusable) return { binary, reused: true }; + await ensureOwnedDirectory(root, "bin"); + const reusable = await existingVerified(root, packageJson.version, target, artifact.archiveSha256, artifact.binarySha256); + if (reusable !== undefined) return { binary: reusable, reused: true }; + const binary = path.join(root, CANONICAL_BINARY); + const statePath = path.join(root, "install-state.json"); const staging = `${binary}.tmp-${process.pid}-${Date.now()}`; + let stagingCreated = false; try { const response = await fetchImpl(artifact.url); if (!response?.ok) throw new ContextLoaderPackageError("download-failed", `could not download the ${target} artifact`); @@ -30,17 +43,24 @@ export async function installContextLoader({ packageRoot, fetchImpl = fetch, pla if (sha256(content) !== artifact.binarySha256) { throw new ContextLoaderPackageError("binary-digest-mismatch", `extracted ${target} binary did not match its SHA-256`); } - await mkdir(path.dirname(binary), { recursive: true }); - await writeFile(staging, content, { mode: 0o755 }); + await ensureOwnedDirectory(root, "bin"); + await writeNewOwnedFile(root, path.join("bin", path.basename(staging)), content, 0o755); + stagingCreated = true; await chmod(staging, 0o755); + await ownedRegularFile(root, path.join("bin", path.basename(staging))); + await ensureOwnedDirectory(root, "bin"); await rename(staging, binary); + stagingCreated = false; + const installedBinary = await ownedRegularFile(root, CANONICAL_BINARY); const state = { package: packageJson.name, version: packageJson.version, target, archiveSha256: artifact.archiveSha256, binarySha256: artifact.binarySha256, binaryPath: CANONICAL_BINARY }; - const stateTmp = `${statePath}.tmp-${process.pid}-${Date.now()}`; - await writeFile(stateTmp, `${JSON.stringify(state)}\n`, { mode: 0o600 }); + const stateName = `install-state.json.tmp-${process.pid}-${Date.now()}`; + const stateTmp = path.join(root, stateName); + await writeNewOwnedFile(root, stateName, `${JSON.stringify(state)}\n`, 0o600); await rename(stateTmp, statePath); - return { binary, reused: false }; + await ownedRegularFile(root, "install-state.json"); + return { binary: installedBinary, reused: false }; } catch (error) { - await rm(staging, { force: true }); + if (stagingCreated) await removeOwnedFile(root, path.join("bin", path.basename(staging))); throw error instanceof ContextLoaderPackageError ? error : new ContextLoaderPackageError("download-failed", "could not download the required artifact (offline cache miss)"); } } @@ -51,14 +71,19 @@ export async function installContextLoader({ packageRoot, fetchImpl = fetch, pla * packed packages always download and verify their tagged artifact. */ export async function isRepositorySourcePackage(packageRoot) { - const repositoryRoot = path.resolve(packageRoot, "..", ".."); - if (path.resolve(packageRoot) !== path.join(repositoryRoot, "apps", "context-loader")) return false; try { - await Promise.all([ - access(path.join(repositoryRoot, ".git")), - access(path.join(repositoryRoot, "pnpm-workspace.yaml")), - access(path.join(repositoryRoot, "core", "wren", "pyproject.toml")), - ]); + if (!lstatSync(packageRoot).isDirectory()) return false; + const physicalPackage = await realpath(packageRoot); + const { stdout: repositoryOutput } = await execFile("git", ["-C", physicalPackage, "rev-parse", "--show-toplevel"], { timeout: 2_000 }); + const repositoryRoot = await realpath(repositoryOutput.trim()); + if (physicalPackage !== path.join(repositoryRoot, "apps", "context-loader")) return false; + const { stdout: originOutput } = await execFile("git", ["-C", repositoryRoot, "remote", "get-url", "origin"], { timeout: 2_000 }); + if (!SOURCE_ORIGINS.has(originOutput.trim())) return false; + await execFile( + "git", + ["-C", repositoryRoot, "ls-files", "--error-unmatch", "--", "apps/context-loader/package.json", "pnpm-workspace.yaml", "core/wren/pyproject.toml"], + { timeout: 2_000 }, + ); return true; } catch { return false; @@ -75,15 +100,91 @@ function validateManifest(packageJson, manifest, artifact, target) { } } -async function existingVerified(binary, statePath, version, target, digest) { +async function existingVerified(root, version, target, archiveDigest, binaryDigest) { try { - const state = JSON.parse(await readFile(statePath, "utf8")); - return state.version === version && state.target === target && state.binaryPath === CANONICAL_BINARY && state.binarySha256 === digest && sha256File(binary) === digest; + const stateFile = await ownedRegularFile(root, "install-state.json", { allowMissing: true }); + const binary = await ownedRegularFile(root, CANONICAL_BINARY, { allowMissing: true }); + if (stateFile === undefined || binary === undefined) return undefined; + const state = JSON.parse(await readFile(stateFile, "utf8")); + return state.version === version && state.target === target && state.binaryPath === CANONICAL_BINARY && state.archiveSha256 === archiveDigest && state.binarySha256 === binaryDigest && sha256File(binary) === binaryDigest ? binary : undefined; } catch { - return false; + throw new ContextLoaderPackageError("unsafe-path", "existing install state or binary is linked, malformed, or outside the package root"); } } +async function physicalPackageRoot(packageRoot) { + try { + if (!(await lstat(packageRoot)).isDirectory()) throw new Error("not a directory"); + return await realpath(packageRoot); + } catch { + throw new ContextLoaderPackageError("unsafe-path", "package root is missing, linked, or not a real directory"); + } +} + +async function readOwnedJson(root, relativePath, label) { + const file = await ownedRegularFile(root, relativePath); + try { + const value = JSON.parse(await readFile(file, "utf8")); + if (typeof value !== "object" || value === null) throw new Error("not an object"); + return value; + } catch { + throw new ContextLoaderPackageError("invalid-manifest", `${label} is missing or invalid JSON`); + } +} + +async function ensureOwnedDirectory(root, relativePath) { + const directory = path.join(root, relativePath); + try { + await mkdir(directory, { recursive: true }); + } catch { + throw new ContextLoaderPackageError("unsafe-path", "could not create the package-local binary directory"); + } + try { + if (!(await lstat(directory)).isDirectory()) throw new Error("not a directory"); + if ((await realpath(directory)) !== directory) throw new Error("linked directory"); + } catch { + throw new ContextLoaderPackageError("unsafe-path", "package-local binary directory is linked or outside the package root"); + } +} + +async function ownedRegularFile(root, relativePath, { allowMissing = false } = {}) { + const lexical = path.resolve(root, relativePath); + if (!lexical.startsWith(`${root}${path.sep}`)) throw new ContextLoaderPackageError("unsafe-path", "requested file escapes the package root"); + try { + if (!(await lstat(lexical)).isFile()) throw new Error("not a regular file"); + const physical = await realpath(lexical); + if (!physical.startsWith(`${root}${path.sep}`) || !(await stat(physical)).isFile()) throw new Error("not contained"); + return physical; + } catch (error) { + if (allowMissing && isMissing(error)) return undefined; + throw new ContextLoaderPackageError("unsafe-path", "package-local file is linked, missing, or outside the package root"); + } +} + +async function writeNewOwnedFile(root, relativePath, content, mode) { + const lexical = path.resolve(root, relativePath); + if (!lexical.startsWith(`${root}${path.sep}`)) throw new ContextLoaderPackageError("unsafe-path", "staging file escapes the package root"); + const handle = await open(lexical, "wx", mode); + try { + await handle.writeFile(content); + } finally { + await handle.close(); + } +} + +async function removeOwnedFile(root, relativePath) { + try { + const file = await ownedRegularFile(root, relativePath, { allowMissing: true }); + if (file !== undefined) await rm(file, { force: true }); + } catch { + // Never follow an unexpected link while cleaning up an interrupted install. + } +} + +function isMissing(error) { + return typeof error === "object" && error !== null && error.code === "ENOENT"; +} + function sha256(value) { return createHash("sha256").update(value).digest("hex"); } diff --git a/apps/context-loader/test/installer.test.mjs b/apps/context-loader/test/installer.test.mjs index 8c7766469..fc5055292 100644 --- a/apps/context-loader/test/installer.test.mjs +++ b/apps/context-loader/test/installer.test.mjs @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { gzipSync } from "node:zlib"; import { installContextLoader, isRepositorySourcePackage } from "../scripts/installer.mjs"; import { readVerifiedState } from "../lib/verified.mjs"; @@ -95,17 +97,49 @@ test("rejects final-file and ancestor-directory symlinks at runtime", async () = assert.throws(() => readVerifiedState(ancestorLink.root), /linked|outside/); }); -test("skips only the verifiable source-workspace layout, never an equivalent packed copy", async () => { - const repositoryRoot = await mkdtemp(path.join(os.tmpdir(), "context-loader-source-")); - const sourcePackage = path.join(repositoryRoot, "apps", "context-loader"); - await mkdir(path.join(repositoryRoot, "core", "wren"), { recursive: true }); - await mkdir(sourcePackage, { recursive: true }); - await writeFile(path.join(repositoryRoot, ".git"), "gitdir: fixture\n"); - await writeFile(path.join(repositoryRoot, "pnpm-workspace.yaml"), "packages: []\n"); - await writeFile(path.join(repositoryRoot, "core", "wren", "pyproject.toml"), "[project]\nname = \"wren\"\n"); +test("refuses a same-bytes external binary during reuse and a bin-directory link before any install write", async () => { + const reusable = await fixture(); + const installed = await installContextLoader({ packageRoot: reusable.root, fetchImpl: fetchArchive(reusable.archive), platform: "darwin", arch: "arm64" }); + const external = path.join(await mkdtemp(path.join(os.tmpdir(), "context-loader-reuse-external-")), "wren-context-loader"); + await writeFile(external, await readFile(installed.binary), { mode: 0o755 }); + await rm(installed.binary); + await symlink(external, installed.binary); + await assert.rejects( + installContextLoader({ packageRoot: reusable.root, fetchImpl: fetchArchive(reusable.archive), platform: "darwin", arch: "arm64" }), + /unsafe-path/, + ); + assert.deepEqual(await readFile(external), reusable.artifact); + + const fresh = await fixture(); + const externalDir = await mkdtemp(path.join(os.tmpdir(), "context-loader-write-external-")); + const sentinel = path.join(externalDir, "wren-context-loader"); + await writeFile(sentinel, "outside-before", { mode: 0o755 }); + await symlink(externalDir, path.join(fresh.root, "bin")); + await assert.rejects( + installContextLoader({ packageRoot: fresh.root, fetchImpl: fetchArchive(fresh.archive), platform: "darwin", arch: "arm64" }), + /unsafe-path/, + ); + assert.equal(await readFile(sentinel, "utf8"), "outside-before"); + assert.deepEqual(await readdir(externalDir), ["wren-context-loader"]); +}); + +test("skips only the tracked source checkout, never a lookalike git repository or packed copy", async () => { + const sourcePackage = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); assert.equal(await isRepositorySourcePackage(sourcePackage), true); - const packedCopy = path.join(repositoryRoot, "node_modules", "@wrenai", "context-loader"); + const fakeRoot = await mkdtemp(path.join(os.tmpdir(), "context-loader-fake-source-")); + const fakePackage = path.join(fakeRoot, "apps", "context-loader"); + await mkdir(path.join(fakeRoot, "core", "wren"), { recursive: true }); + await mkdir(fakePackage, { recursive: true }); + await writeFile(path.join(fakePackage, "package.json"), "{}\n"); + await writeFile(path.join(fakeRoot, "pnpm-workspace.yaml"), "packages: []\n"); + await writeFile(path.join(fakeRoot, "core", "wren", "pyproject.toml"), "[project]\nname = \"wren\"\n"); + execFileSync("git", ["init", fakeRoot]); + execFileSync("git", ["-C", fakeRoot, "add", "apps/context-loader/package.json", "pnpm-workspace.yaml", "core/wren/pyproject.toml"]); + execFileSync("git", ["-C", fakeRoot, "remote", "add", "origin", "https://example.invalid/lookalike.git"]); + assert.equal(await isRepositorySourcePackage(fakePackage), false); + + const packedCopy = path.join(fakeRoot, "node_modules", "@wrenai", "context-loader"); await mkdir(packedCopy, { recursive: true }); assert.equal(await isRepositorySourcePackage(packedCopy), false); });