fix: validate plugin package extension entries

This commit is contained in:
Peter Steinberger
2026-04-26 11:01:01 +01:00
parent d22d6aed16
commit f33a812c07
4 changed files with 251 additions and 38 deletions

View File

@@ -19,6 +19,7 @@ import {
type OpenClawPackageManifest,
type PackageManifest,
} from "./manifest.js";
import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js";
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js";
@@ -613,10 +614,6 @@ function resolvePackageEntrySource(params: {
return openCandidate(source);
}
function isTypeScriptPackageEntry(entryPath: string): boolean {
return [".ts", ".mts", ".cts"].includes(normalizeLowercaseStringOrEmpty(path.extname(entryPath)));
}
function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean {
return origin === "config" || origin === "global";
}
@@ -663,28 +660,6 @@ function resolveSafePackageEntry(params: {
return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") };
}
function listBuiltRuntimeEntryCandidates(entryPath: string): string[] {
if (!isTypeScriptPackageEntry(entryPath)) {
return [];
}
const normalized = entryPath.replace(/\\/g, "/");
const withoutExtension = normalized.replace(/\.[^.]+$/u, "");
const normalizedRelative = normalized.replace(/^\.\//u, "");
const distWithoutExtension = normalizedRelative.startsWith("src/")
? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}`
: `./dist/${withoutExtension.replace(/^\.\//u, "")}`;
const withJavaScriptExtensions = (basePath: string) => [
`${basePath}.js`,
`${basePath}.mjs`,
`${basePath}.cjs`,
];
const candidates = [
...withJavaScriptExtensions(distWithoutExtension),
...withJavaScriptExtensions(withoutExtension),
];
return [...new Set(candidates)].filter((candidate) => candidate !== normalized);
}
function resolveExistingPackageEntrySource(params: {
packageDir: string;
entryPath: string;

View File

@@ -790,6 +790,81 @@ describe("installPluginFromArchive", () => {
expect.unreachable("expected install to fail without openclaw.extensions");
});
it("rejects package installs when openclaw.extensions entries escape the package", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "escaping-entry-plugin",
version: "1.0.0",
openclaw: {
extensions: ["../src/index.ts"],
runtimeExtensions: ["./dist/index.js"],
},
}),
);
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n");
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
expect(result.error).toContain("extension entry escapes plugin directory");
}
});
it("rejects package installs when no extension runtime entry exists", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "missing-entry-plugin",
version: "1.0.0",
openclaw: { extensions: ["./dist/index.js"] },
}),
);
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
expect(result.error).toContain("extension entry not found");
}
});
it("allows missing TypeScript source entries when an inferred built runtime entry exists", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "inferred-runtime-plugin",
version: "1.0.0",
openclaw: { extensions: ["./src/index.ts"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n");
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.pluginId).toBe("inferred-runtime-plugin");
}
});
it("blocks package installs when plugin contains dangerous code patterns", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();

View File

@@ -1,5 +1,8 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { matchBoundaryFileOpenFailure, openBoundaryFile } from "../infra/boundary-file-read.js";
import { resolveBoundaryPath } from "../infra/boundary-path.js";
import {
packageNameMatchesId,
resolveSafeInstallDir,
@@ -14,9 +17,11 @@ import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import type { InstallSecurityScanResult } from "./install-security-scan.js";
import type { InstallSafetyOverrides } from "./install-security-scan.js";
import {
getPackageManifestMetadata,
resolvePackageExtensionEntries,
type PackageManifest as PluginPackageManifest,
} from "./manifest.js";
import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js";
let pluginInstallRuntimePromise: Promise<typeof import("./install.runtime.js")> | undefined;
@@ -54,6 +59,7 @@ export const PLUGIN_INSTALL_ERROR_CODE = {
MISSING_OPENCLAW_EXTENSIONS: "missing_openclaw_extensions",
MISSING_PLUGIN_MANIFEST: "missing_plugin_manifest",
EMPTY_OPENCLAW_EXTENSIONS: "empty_openclaw_extensions",
INVALID_OPENCLAW_EXTENSIONS: "invalid_openclaw_extensions",
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
PLUGIN_ID_MISMATCH: "plugin_id_mismatch",
SECURITY_SCAN_BLOCKED: "security_scan_blocked",
@@ -186,6 +192,139 @@ function ensureOpenClawExtensions(params: { manifest: PackageManifest }):
};
}
type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string };
async function validatePackageExtensionEntry(params: {
packageDir: string;
entry: string;
label: string;
requireExisting: boolean;
}): Promise<ExtensionEntryValidation> {
const absolutePath = path.resolve(params.packageDir, params.entry);
try {
const resolved = await resolveBoundaryPath({
absolutePath,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
});
if (!resolved.exists) {
return params.requireExisting
? { ok: false, error: `${params.label} not found: ${params.entry}` }
: { ok: true, exists: false };
}
} catch {
return {
ok: false,
error: `${params.label} escapes plugin directory: ${params.entry}`,
};
}
const opened = await openBoundaryFile({
absolutePath,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
});
if (!opened.ok) {
return matchBoundaryFileOpenFailure(opened, {
path: () => ({ ok: false, error: `${params.label} not found: ${params.entry}` }),
io: () => ({ ok: false, error: `${params.label} unreadable: ${params.entry}` }),
validation: () => ({
ok: false,
error: `${params.label} failed plugin directory boundary checks: ${params.entry}`,
}),
fallback: () => ({
ok: false,
error: `${params.label} failed plugin directory boundary checks: ${params.entry}`,
}),
});
}
fsSync.closeSync(opened.fd);
return { ok: true, exists: true };
}
async function validatePackageExtensionEntries(params: {
packageDir: string;
extensions: string[];
manifest: PackageManifest;
}): Promise<{ ok: true } | { ok: false; error: string; code: PluginInstallErrorCode }> {
const packageMetadata = getPackageManifestMetadata(params.manifest);
const runtimeExtensions = Array.isArray(packageMetadata?.runtimeExtensions)
? packageMetadata.runtimeExtensions
.map((entry) => normalizeOptionalString(entry) ?? "")
.filter(Boolean)
: [];
const useRuntimeExtensions = runtimeExtensions.length === params.extensions.length;
for (const [index, entry] of params.extensions.entries()) {
const sourceEntry = await validatePackageExtensionEntry({
packageDir: params.packageDir,
entry,
label: "extension entry",
requireExisting: false,
});
if (!sourceEntry.ok) {
return {
ok: false,
error: sourceEntry.error,
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
};
}
const runtimeEntry = useRuntimeExtensions ? runtimeExtensions[index] : undefined;
if (runtimeEntry) {
const runtimeResult = await validatePackageExtensionEntry({
packageDir: params.packageDir,
entry: runtimeEntry,
label: "runtime extension entry",
requireExisting: true,
});
if (!runtimeResult.ok) {
return {
ok: false,
error: runtimeResult.error,
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
};
}
continue;
}
if (sourceEntry.exists) {
continue;
}
let foundBuiltEntry = false;
for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) {
const builtResult = await validatePackageExtensionEntry({
packageDir: params.packageDir,
entry: builtEntry,
label: "inferred runtime extension entry",
requireExisting: false,
});
if (!builtResult.ok) {
return {
ok: false,
error: builtResult.error,
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
};
}
if (builtResult.exists) {
foundBuiltEntry = true;
break;
}
}
if (!foundBuiltEntry) {
return {
ok: false,
error: `extension entry not found: ${entry}`,
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
};
}
}
return { ok: true };
}
function isNpmPackageNotFoundMessage(error: string): boolean {
const normalized = error.trim();
if (normalized.startsWith("Package not found on npm:")) {
@@ -766,6 +905,15 @@ async function installPluginFromPackageDir(
};
}
const extensionValidation = await validatePackageExtensionEntries({
packageDir: params.packageDir,
extensions,
manifest,
});
if (!extensionValidation.ok) {
return extensionValidation;
}
const targetResult = await resolvePreparedDirectoryInstallTarget({
runtime,
pluginId,
@@ -819,18 +967,6 @@ async function installPluginFromPackageDir(
hasDeps: Object.keys(deps).length > 0,
depsLogMessage: "Installing plugin dependencies…",
nameEncoder: encodePluginInstallDirName,
afterCopy: async (installedDir) => {
for (const entry of extensions) {
const resolvedEntry = path.resolve(installedDir, entry);
if (!runtime.isPathInside(installedDir, resolvedEntry)) {
logger.warn?.(`extension entry escapes plugin directory: ${entry}`);
continue;
}
if (!(await runtime.fileExists(resolvedEntry))) {
logger.warn?.(`extension entry not found: ${entry}`);
}
}
},
afterInstall: async (installedDir) => {
// Run the dependency-tree security scan BEFORE linking peer deps.
// The scan rejects any node_modules/ symlink whose target resolves

View File

@@ -0,0 +1,27 @@
import path from "node:path";
export function isTypeScriptPackageEntry(entryPath: string): boolean {
return [".ts", ".mts", ".cts"].includes(path.extname(entryPath).toLowerCase());
}
export function listBuiltRuntimeEntryCandidates(entryPath: string): string[] {
if (!isTypeScriptPackageEntry(entryPath)) {
return [];
}
const normalized = entryPath.replace(/\\/g, "/");
const withoutExtension = normalized.replace(/\.[^.]+$/u, "");
const normalizedRelative = normalized.replace(/^\.\//u, "");
const distWithoutExtension = normalizedRelative.startsWith("src/")
? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}`
: `./dist/${withoutExtension.replace(/^\.\//u, "")}`;
const withJavaScriptExtensions = (basePath: string) => [
`${basePath}.js`,
`${basePath}.mjs`,
`${basePath}.cjs`,
];
const candidates = [
...withJavaScriptExtensions(distWithoutExtension),
...withJavaScriptExtensions(withoutExtension),
];
return [...new Set(candidates)].filter((candidate) => candidate !== normalized);
}