mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:20:45 +00:00
fix: validate plugin package extension entries
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
27
src/plugins/package-entrypoints.ts
Normal file
27
src/plugins/package-entrypoints.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user