diff --git a/openclaw/CHANGELOG.md b/openclaw/CHANGELOG.md index f75fa2c59..5e9b271f1 100644 --- a/openclaw/CHANGELOG.md +++ b/openclaw/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to the `@mem0/openclaw-mem0` plugin will be documented in this file. +## [1.0.3] - 2026-04-03 + +### Fixed +- **Path traversal vulnerability**: Added `safePath()` containment helper to `readSkillFile` and `readDomainOverlay` in `skill-loader.ts` — prevents directory traversal via `config.domain` or the exported `loadSkill` API +- **Noise filter regression**: Reverted incorrect `After-Compaction` regex rename back to `Post-Compaction` so the filter correctly matches real upstream compaction audit messages +- **Cosmetic revert**: Restored `// Over-fetch for ranking` comment in `recall.ts` (was changed to work around a false-positive scanner match on the substring `fetch`) + +### Changed +- **Supply-chain hardening**: Pinned `mem0ai` dependency to exact `2.3.0` (was `^2.3.0`) + +### Added +- **Path traversal tests**: 12 new tests covering `safePath`, `readSkillFile`, `readDomainOverlay`, and `loadSkill` with traversal inputs + +## [1.0.2] - 2026-04-02 + +### Fixed +- **Security scanner warning**: Removed `resolveEnvVars()` and `resolveEnvVarsDeep()` from `config.ts` — OpenClaw already resolves `${VAR}` in `openclaw.json` before passing config to the plugin, so plugin-side env resolution was redundant and triggered the "credential harvesting" static analysis warning ([#4676](https://github.com/mem0ai/mem0/pull/4676)) + ## [1.0.1] - 2026-04-02 ### Added diff --git a/openclaw/package.json b/openclaw/package.json index 7f4d42406..1d7310a53 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/openclaw-mem0", - "version": "1.0.2", + "version": "1.0.3", "type": "module", "description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source", "license": "Apache-2.0", @@ -35,7 +35,7 @@ }, "dependencies": { "@sinclair/typebox": "0.34.47", - "mem0ai": "^2.3.0" + "mem0ai": "2.3.0" }, "openclaw": { "extensions": [ diff --git a/openclaw/pnpm-lock.yaml b/openclaw/pnpm-lock.yaml index fa6c23b34..ad201b224 100644 --- a/openclaw/pnpm-lock.yaml +++ b/openclaw/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 0.34.47 version: 0.34.47 mem0ai: - specifier: ^2.3.0 - version: 2.4.0(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0) + specifier: 2.3.0 + version: 2.3.0(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0) devDependencies: '@types/node': specifier: ^22.15.0 @@ -1466,8 +1466,8 @@ packages: md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} - mem0ai@2.4.0: - resolution: {integrity: sha512-b1V8KXUse8ySvio8D0bRHOvgxa9BlmP4g4bjtEnA3uILcu+2PAtcAiHDPdBxUIeoxTxAeJM+0wBGaMIps4bIaw==} + mem0ai@2.3.0: + resolution: {integrity: sha512-9e0B9hgM7nxnKDD6zmSRkfkrQ2sa9tGdpZE3CPsp7eIu18soYbXvm9YT08mllCYuiW+EdEW7L/+DNCphOfvkKQ==} engines: {node: '>=18'} peerDependencies: '@anthropic-ai/sdk': ^0.40.1 @@ -3543,7 +3543,7 @@ snapshots: crypt: 0.0.2 is-buffer: 1.1.6 - mem0ai@2.4.0(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0): + mem0ai@2.3.0(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260313.1)(@google/genai@1.45.0)(@langchain/core@0.3.80(openai@4.104.0(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.99.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.8.0)(cloudflare@4.5.0)(groq-sdk@0.3.0)(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.19.0): dependencies: '@anthropic-ai/sdk': 0.40.1 '@azure/identity': 4.13.0 diff --git a/openclaw/skill-loader.test.ts b/openclaw/skill-loader.test.ts new file mode 100644 index 000000000..ab2125e54 --- /dev/null +++ b/openclaw/skill-loader.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for path traversal prevention in skill-loader. + */ +import { describe, it, expect } from "vitest"; +import { safePath, loadSkill } from "./skill-loader.ts"; + +// --------------------------------------------------------------------------- +// safePath — path containment +// --------------------------------------------------------------------------- +describe("safePath", () => { + it("rejects parent directory traversal", () => { + expect(safePath("../../etc/passwd")).toBeNull(); + }); + + it("rejects deep traversal", () => { + expect(safePath("../../../etc/shadow")).toBeNull(); + }); + + it("rejects traversal in nested segment", () => { + expect(safePath("valid", "../../etc")).toBeNull(); + }); + + it("rejects bare '..' as segment", () => { + expect(safePath("..")).toBeNull(); + }); + + it("accepts valid skill paths", () => { + expect(safePath("memory-triage", "SKILL.md")).not.toBeNull(); + }); + + it("accepts valid domain overlay paths", () => { + expect(safePath("memory-triage", "domains", "companion.md")).not.toBeNull(); + }); + + it("returns null for empty segments that resolve to skills root with subpath escape", () => { + // path.resolve("skills", "", "../../etc") still escapes + expect(safePath("", "../../etc")).toBeNull(); + }); + + it("rejects traversal disguised with valid prefix", () => { + expect(safePath("memory-triage/../../etc/passwd")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// loadSkill — integration tests for traversal prevention +// --------------------------------------------------------------------------- +describe("loadSkill path traversal", () => { + it("returns null for traversal skillName", () => { + expect(loadSkill("../../etc/passwd")).toBeNull(); + }); + + it("returns null for deep traversal skillName", () => { + expect(loadSkill("../../../..")).toBeNull(); + }); + + it("loads a valid skill", () => { + const result = loadSkill("memory-triage"); + expect(result).not.toBeNull(); + expect(result?.prompt).toBeTruthy(); + }); + + it("blocks domain traversal while loading valid skill", () => { + // Valid skill name, malicious domain — should load skill but skip the overlay + const result = loadSkill("memory-triage", { domain: "../../etc/passwd" }); + // Should still succeed (skill itself is valid), domain overlay is just skipped + expect(result).not.toBeNull(); + expect(result?.prompt).toBeTruthy(); + }); +}); diff --git a/openclaw/skill-loader.ts b/openclaw/skill-loader.ts index 3e265b472..abe443023 100644 --- a/openclaw/skill-loader.ts +++ b/openclaw/skill-loader.ts @@ -110,10 +110,26 @@ function resolveSkillsDir(): string { } const SKILLS_DIR = resolveSkillsDir(); +const RESOLVED_SKILLS_DIR = path.resolve(SKILLS_DIR); + +/** + * Resolve path segments under SKILLS_DIR and verify the result doesn't escape. + * Returns null if the resolved path is outside the skills directory (path traversal). + * Note: path.resolve follows symlinks lexically; the skills directory is + * package-owned so symlink escape is not a practical concern. + */ +export function safePath(...segments: string[]): string | null { + const resolved = path.resolve(SKILLS_DIR, ...segments); + if (resolved !== RESOLVED_SKILLS_DIR && + !resolved.startsWith(RESOLVED_SKILLS_DIR + path.sep)) { + return null; + } + return resolved; +} function readSkillFile(skillName: string): string | null { - // Skills use OpenClaw directory format: /SKILL.md - const filePath = path.join(SKILLS_DIR, skillName, "SKILL.md"); + const filePath = safePath(skillName, "SKILL.md"); + if (!filePath) return null; try { return fs.readFileSync(filePath, "utf-8"); } catch { @@ -127,8 +143,8 @@ function readSkillFile(skillName: string): string | null { * The `applies_to` frontmatter field is checked for backward compatibility. */ function readDomainOverlay(domain: string, targetSkill: string): string | null { - // Domain overlays are stored inside the target skill's directory - const filePath = path.join(SKILLS_DIR, targetSkill, "domains", `${domain}.md`); + const filePath = safePath(targetSkill, "domains", `${domain}.md`); + if (!filePath) return null; try { const content = fs.readFileSync(filePath, "utf-8"); const parsed = parseSkillFile(content);