fix(security): sanitize file paths in agent-state and skills-remove endpoints (#60)

Co-authored-by: Shams <shams@openclaw.dev>
This commit is contained in:
AdminExcellence
2026-03-27 20:21:03 -07:00
committed by GitHub
parent 3295e1ea0e
commit e0eb73111b
3 changed files with 18 additions and 3 deletions
-1
View File
@@ -5991,7 +5991,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
+5 -1
View File
@@ -83,10 +83,14 @@ export const restoreAgentStateLocally = (params: {
} }
const base = resolveStateDir(); const base = resolveStateDir();
const trashRoot = path.join(base, "trash", "studio-delete-agent");
if (!fs.existsSync(trashDirRaw)) { if (!fs.existsSync(trashDirRaw)) {
throw new Error(`trashDir does not exist: ${trashDirRaw}`); throw new Error(`trashDir does not exist: ${trashDirRaw}`);
} }
const { resolvedCandidate: resolvedTrashDir } = ensureUnderBase(base, trashDirRaw); // Validate trashDir is strictly under the expected trash root, not just anywhere under base.
// This prevents path traversal where an attacker could reference legitimate directories
// (e.g. base/agents/) as a "trashDir" and cause unintended file moves.
const { resolvedCandidate: resolvedTrashDir } = ensureUnderBase(trashRoot, trashDirRaw);
const moves: GatewayAgentStateMove[] = []; const moves: GatewayAgentStateMove[] = [];
const restoreIfExists = (src: string, dest: string) => { const restoreIfExists = (src: string, dest: string) => {
+13 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { resolveUserPath } from "@/lib/clawdbot/paths"; import { resolveStateDir, resolveUserPath } from "@/lib/clawdbot/paths";
import type { RemovableSkillSource, SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types"; import type { RemovableSkillSource, SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
const resolveComparablePath = (input: string): string => { const resolveComparablePath = (input: string): string => {
@@ -57,6 +57,18 @@ export const removeSkillLocally = (params: SkillRemoveRequest): SkillRemoveResul
const workspaceDir = normalizeRequiredPath(params.workspaceDir, "workspaceDir"); const workspaceDir = normalizeRequiredPath(params.workspaceDir, "workspaceDir");
const managedSkillsDir = normalizeRequiredPath(params.managedSkillsDir, "managedSkillsDir"); 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({ const allowedRoot = resolveAllowedRoot({
source, source,
workspaceDir, workspaceDir,