fix(security): enforce plugin and hook path containment

This commit is contained in:
Peter Steinberger
2026-02-19 15:34:58 +01:00
parent 10379e7dcd
commit 81b19aaa1a
14 changed files with 387 additions and 8 deletions

View File

@@ -489,7 +489,6 @@ describe("loadOpenClawPlugins", () => {
expect(loaded?.origin).toBe("config");
expect(overridden?.origin).toBe("bundled");
});
it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
@@ -561,4 +560,48 @@ describe("loadOpenClawPlugins", () => {
}
}
});
it("rejects plugin entry files that escape plugin root via symlink", () => {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const pluginDir = makeTempDir();
const outsideDir = makeTempDir();
const outsideEntry = path.join(outsideDir, "outside.js");
const linkedEntry = path.join(pluginDir, "entry.js");
fs.writeFileSync(
outsideEntry,
'export default { id: "symlinked", register() { throw new Error("should not run"); } };',
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "symlinked",
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
try {
fs.symlinkSync(outsideEntry, linkedEntry);
} catch {
return;
}
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [linkedEntry] },
allow: ["symlinked"],
},
},
});
const record = registry.plugins.find((entry) => entry.id === "symlinked");
expect(record?.status).not.toBe("loaded");
expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true);
});
});