fix(skills): constrain plugin skill paths

This commit is contained in:
Peter Steinberger
2026-03-01 23:45:29 +00:00
parent 4614222572
commit 23f434f98d
2 changed files with 77 additions and 0 deletions

View File

@@ -100,4 +100,76 @@ describe("resolvePluginSkillDirs", () => {
expect(dirs).toEqual([path.resolve(helperRoot, "skills")]);
});
it("rejects plugin skill paths that escape the plugin root", async () => {
const workspaceDir = await tempDirs.make("openclaw-");
const pluginRoot = await tempDirs.make("openclaw-plugin-");
const outsideDir = await tempDirs.make("openclaw-outside-");
const outsideSkills = path.join(outsideDir, "skills");
await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true });
await fs.mkdir(outsideSkills, { recursive: true });
const escapePath = path.relative(pluginRoot, outsideSkills);
hoisted.loadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [
{
id: "helper",
name: "Helper",
channels: [],
providers: [],
skills: ["./skills", escapePath],
origin: "workspace",
rootDir: pluginRoot,
source: pluginRoot,
manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
},
],
} satisfies PluginManifestRegistry);
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {} as OpenClawConfig,
});
expect(dirs).toEqual([path.resolve(pluginRoot, "skills")]);
});
it("rejects plugin skill symlinks that resolve outside plugin root", async () => {
const workspaceDir = await tempDirs.make("openclaw-");
const pluginRoot = await tempDirs.make("openclaw-plugin-");
const outsideDir = await tempDirs.make("openclaw-outside-");
const outsideSkills = path.join(outsideDir, "skills");
const linkPath = path.join(pluginRoot, "skills-link");
await fs.mkdir(outsideSkills, { recursive: true });
await fs.symlink(
outsideSkills,
linkPath,
process.platform === "win32" ? ("junction" as const) : ("dir" as const),
);
hoisted.loadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [
{
id: "helper",
name: "Helper",
channels: [],
providers: [],
skills: ["./skills-link"],
origin: "workspace",
rootDir: pluginRoot,
source: pluginRoot,
manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
},
],
} satisfies PluginManifestRegistry);
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {} as OpenClawConfig,
});
expect(dirs).toEqual([]);
});
});

View File

@@ -8,6 +8,7 @@ import {
resolveMemorySlotDecision,
} from "../../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import { isPathInsideWithRealpath } from "../../security/scan-paths.js";
const log = createSubsystemLogger("skills");
@@ -72,6 +73,10 @@ export function resolvePluginSkillDirs(params: {
log.warn(`plugin skill path not found (${record.id}): ${candidate}`);
continue;
}
if (!isPathInsideWithRealpath(record.rootDir, candidate, { requireRealpath: true })) {
log.warn(`plugin skill path escapes plugin root (${record.id}): ${candidate}`);
continue;
}
if (seen.has(candidate)) {
continue;
}