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:
Luke The Dev
2026-03-27 22:21:41 -05:00
committed by GitHub
parent e0eb73111b
commit c3556d2daa
10 changed files with 69 additions and 21 deletions
+3
View File
@@ -63,6 +63,9 @@ export async function POST(request: Request) {
message.includes("agentId is required") ||
message.includes("trashDir is required") ||
message.includes("Invalid agentId") ||
message.includes("trashDir does not exist") ||
message.includes("trashDir is not under") ||
message.includes("Refusing to restore over existing path") ||
message.includes("Gateway URL is missing") ||
message.includes("Invalid gateway URL") ||
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
@@ -76,6 +76,7 @@ export async function POST(request: Request) {
message.includes("Unsupported skill source") ||
message.includes("Refusing to remove") ||
message.includes("not a directory") ||
message.includes("Remote workspace skill removal is not supported over SSH") ||
message.includes("Gateway URL is missing") ||
message.includes("Invalid gateway URL") ||
message.includes("require OPENCLAW_GATEWAY_SSH_TARGET")
+1 -13
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveStateDir, resolveUserPath } from "@/lib/clawdbot/paths";
import { resolveUserPath } from "@/lib/clawdbot/paths";
import type { RemovableSkillSource, SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
const resolveComparablePath = (input: string): string => {
@@ -57,18 +57,6 @@ export const removeSkillLocally = (params: SkillRemoveRequest): SkillRemoveResul
const workspaceDir = normalizeRequiredPath(params.workspaceDir, "workspaceDir");
const managedSkillsDir = normalizeRequiredPath(params.managedSkillsDir, "managedSkillsDir");
// Security: validate that workspaceDir and managedSkillsDir are under the
// server-side state directory. Without this check, an attacker can supply
// arbitrary values for these fields, effectively controlling the "allowed root"
// and bypassing the isPathInside containment check below.
const stateDir = resolveStateDir();
if (!isPathInside(stateDir, workspaceDir)) {
throw new Error(`workspaceDir is not under the state directory: ${workspaceDir}`);
}
if (!isPathInside(stateDir, managedSkillsDir)) {
throw new Error(`managedSkillsDir is not under the state directory: ${managedSkillsDir}`);
}
const allowedRoot = resolveAllowedRoot({
source,
workspaceDir,
+4 -3
View File
@@ -74,6 +74,7 @@ if not trash_dir_raw:
raise SystemExit("trashDir is required.")
base = pathlib.Path.home() / ".openclaw"
trash_root = base / "trash" / "studio-delete-agent"
trash_dir = pathlib.Path(trash_dir_raw).expanduser()
try:
@@ -81,9 +82,9 @@ try:
except FileNotFoundError:
raise SystemExit(f"trashDir does not exist: {trash_dir_raw}")
resolved_base = base.resolve(strict=False)
if resolved_base not in resolved_trash.parents:
raise SystemExit(f"trashDir is not under {base}: {trash_dir_raw}")
resolved_trash_root = trash_root.resolve(strict=False)
if resolved_trash != resolved_trash_root and resolved_trash_root not in resolved_trash.parents:
raise SystemExit(f"trashDir is not under {trash_root}: {trash_dir_raw}")
moves = []
+4 -4
View File
@@ -35,13 +35,13 @@ if source not in allowed_sources:
raise SystemExit(f"Unsupported skill source for removal: {source}")
base_dir = pathlib.Path(base_dir_raw).expanduser().resolve(strict=False)
workspace_dir = pathlib.Path(workspace_dir_raw).expanduser().resolve(strict=False)
managed_skills_dir = pathlib.Path(managed_skills_dir_raw).expanduser().resolve(strict=False)
state_dir = (pathlib.Path.home() / ".openclaw").resolve(strict=False)
managed_skills_root = (state_dir / "skills").resolve(strict=False)
if source == "openclaw-managed":
allowed_root = managed_skills_dir
allowed_root = managed_skills_root
else:
allowed_root = (workspace_dir / "skills").resolve(strict=False)
raise SystemExit("Remote workspace skill removal is not supported over SSH.")
try:
base_dir.relative_to(allowed_root)