fix(security): close remaining path validation gaps (#77)
Harden the SSH agent-state and skill-removal paths to match the local security model, and avoid rejecting valid local workspace skill removals. Made-with: Cursor Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
@@ -55,5 +55,8 @@ describe("agent state ssh executor", () => {
|
||||
input: expect.stringContaining('python3 - "$1" "$2"'),
|
||||
})
|
||||
);
|
||||
const call = mockedRunSshJson.mock.calls[0]?.[0];
|
||||
expect(call?.input).toContain('trash_root = base / "trash" / "studio-delete-agent"');
|
||||
expect(call?.input).toContain('trashDir is not under {trash_root}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,5 +39,18 @@ describe("agent state local", () => {
|
||||
expect(fs.existsSync(agentDir)).toBe(true);
|
||||
expect(fs.readFileSync(path.join(workspace, "hello.txt"), "utf8")).toBe("hi");
|
||||
});
|
||||
|
||||
it("rejects restore paths outside the agent-state trash root", () => {
|
||||
const stateDir = mkTmpStateDir();
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
const agentId = "test-agent";
|
||||
const fakeTrashDir = path.join(stateDir, "agents", agentId);
|
||||
fs.mkdirSync(fakeTrashDir, { recursive: true });
|
||||
|
||||
expect(() => restoreAgentStateLocally({ agentId, trashDir: fakeTrashDir })).toThrow(
|
||||
"trashDir is not under"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -54,5 +54,8 @@ describe("skills remove ssh executor", () => {
|
||||
input: expect.stringContaining('python3 - "$1" "$2" "$3" "$4" "$5"'),
|
||||
})
|
||||
);
|
||||
const call = mockedRunSshJson.mock.calls[0]?.[0];
|
||||
expect(call?.input).toContain('managed_skills_root = (state_dir / "skills").resolve(strict=False)');
|
||||
expect(call?.input).toContain("Remote workspace skill removal is not supported over SSH.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,23 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { removeSkillLocally } from "@/lib/skills/remove-local";
|
||||
|
||||
const mkTmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "claw3d-skill-remove-"));
|
||||
|
||||
describe("skills remove local", () => {
|
||||
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR;
|
||||
else process.env.OPENCLAW_STATE_DIR = originalStateDir;
|
||||
});
|
||||
|
||||
it("removes a workspace skill directory", () => {
|
||||
process.env.OPENCLAW_STATE_DIR = mkTmpDir();
|
||||
|
||||
const workspaceDir = mkTmpDir();
|
||||
const managedSkillsDir = mkTmpDir();
|
||||
const skillDir = path.join(workspaceDir, "skills", "github");
|
||||
|
||||
@@ -147,4 +147,31 @@ describe("skills remove route", () => {
|
||||
});
|
||||
expect(fs.existsSync(skillDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects remote workspace skill removal over ssh", async () => {
|
||||
writeStudioSettings("ws://example.test:18789");
|
||||
|
||||
mockedSpawnSync.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
stderr: "Remote workspace skill removal is not supported over SSH.",
|
||||
error: undefined,
|
||||
} as never);
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/gateway/skills/remove", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
skillKey: "github",
|
||||
source: "openclaw-workspace",
|
||||
baseDir: "/home/ubuntu/workspace-main/skills/github",
|
||||
workspaceDir: "/home/ubuntu/workspace-main",
|
||||
managedSkillsDir: "/home/ubuntu/.openclaw/skills",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user