fix(security): harden file installs and race-path tests

This commit is contained in:
Peter Steinberger
2026-03-02 19:29:17 +00:00
parent e1bc5cad25
commit dbbd41a2ed
5 changed files with 199 additions and 137 deletions

View File

@@ -20,6 +20,7 @@ vi.mock("../process/exec.js", () => ({
let installPluginFromArchive: typeof import("./install.js").installPluginFromArchive;
let installPluginFromDir: typeof import("./install.js").installPluginFromDir;
let installPluginFromNpmSpec: typeof import("./install.js").installPluginFromNpmSpec;
let installPluginFromPath: typeof import("./install.js").installPluginFromPath;
let runCommandWithTimeout: typeof import("../process/exec.js").runCommandWithTimeout;
let suiteTempRoot = "";
let tempDirCounter = 0;
@@ -308,8 +309,12 @@ afterAll(() => {
});
beforeAll(async () => {
({ installPluginFromArchive, installPluginFromDir, installPluginFromNpmSpec } =
await import("./install.js"));
({
installPluginFromArchive,
installPluginFromDir,
installPluginFromNpmSpec,
installPluginFromPath,
} = await import("./install.js"));
({ runCommandWithTimeout } = await import("../process/exec.js"));
});
@@ -598,6 +603,37 @@ describe("installPluginFromDir", () => {
});
});
describe("installPluginFromPath", () => {
it("blocks hardlink alias overwrites when installing a plain file plugin", async () => {
const baseDir = makeTempDir();
const extensionsDir = path.join(baseDir, "extensions");
const outsideDir = path.join(baseDir, "outside");
fs.mkdirSync(extensionsDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
const sourcePath = path.join(baseDir, "payload.js");
fs.writeFileSync(sourcePath, "console.log('SAFE');\n", "utf-8");
const victimPath = path.join(outsideDir, "victim.js");
fs.writeFileSync(victimPath, "ORIGINAL", "utf-8");
const targetPath = path.join(extensionsDir, "payload.js");
fs.linkSync(victimPath, targetPath);
const result = await installPluginFromPath({
path: sourcePath,
extensionsDir,
mode: "update",
});
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error.toLowerCase()).toMatch(/hardlink|path alias escape/);
expect(fs.readFileSync(victimPath, "utf-8")).toBe("ORIGINAL");
});
});
describe("installPluginFromNpmSpec", () => {
it("uses --ignore-scripts for npm pack and cleans up temp dir", async () => {
const stateDir = makeTempDir();

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { fileExists, readJsonFile, resolveArchiveKind } from "../infra/archive.js";
import { writeFileFromPathWithinRoot } from "../infra/fs-safe.js";
import { resolveExistingInstallPath, withExtractedArchiveRoot } from "../infra/install-flow.js";
import {
resolveInstallModeOptions,
@@ -401,7 +402,15 @@ export async function installPluginFromFile(params: {
}
logger.info?.(`Installing to ${targetFile}`);
await fs.copyFile(filePath, targetFile);
try {
await writeFileFromPathWithinRoot({
rootDir: extensionsDir,
relativePath: path.basename(targetFile),
sourcePath: filePath,
});
} catch (err) {
return { ok: false, error: String(err) };
}
return buildFileInstallResult(pluginId, targetFile);
}