diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..5edfa74 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2025-05-26 - Path Traversal in LocalJsonStore +**Vulnerability:** Path traversal via storage keys in `LocalJsonStore`. +**Learning:** Simple path joining of user-controlled keys is insufficient even with basic sanitization. `path.resolve` combined with `path.relative` or a strict prefix check is necessary to ensure the final path remains within the intended base directory. +**Prevention:** Always use `path.relative(base, resolved)` and check if it starts with `..` to verify boundary compliance. diff --git a/packages/documents/src/index.ts b/packages/documents/src/index.ts index afd98de..bc3f936 100644 --- a/packages/documents/src/index.ts +++ b/packages/documents/src/index.ts @@ -50,11 +50,20 @@ export class LocalJsonStore { private toPath(key: string) { const relative = key .split("/") - .filter(Boolean) + .filter((s) => s && s !== "." && s !== "..") .map(sanitizeSegment) .join(path.sep); - return path.join(this.baseDirectory, `${relative}.json`); + const resolved = path.join(this.baseDirectory, `${relative}.json`); + const absoluteBase = path.resolve(this.baseDirectory); + const absoluteResolved = path.resolve(resolved); + + const relativePath = path.relative(absoluteBase, absoluteResolved); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + throw new Error(`Invalid store key: ${key}`); + } + + return resolved; } private readPath(filePath: string) { diff --git a/packages/documents/src/security.test.ts b/packages/documents/src/security.test.ts new file mode 100644 index 0000000..fedd010 --- /dev/null +++ b/packages/documents/src/security.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { LocalJsonStore } from "./index.js"; +import path from "node:path"; +import fs from "node:fs"; + +describe("LocalJsonStore Security", () => { + const baseDir = path.resolve("./tmp/test-store"); + let store: LocalJsonStore; + + beforeEach(() => { + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true }); + } + store = new LocalJsonStore(baseDir); + }); + + afterEach(() => { + if (fs.existsSync(baseDir)) { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + const leakedFile = path.resolve("./tmp/leaked.json"); + if (fs.existsSync(leakedFile)) { + fs.unlinkSync(leakedFile); + } + }); + + it("should prevent path traversal when writing", () => { + const maliciousKey = "../leaked"; + const data = { secret: "leaked" }; + + // This is expected to fail or at least NOT write outside baseDir after the fix + try { + store.write(maliciousKey, data); + } catch (e) { + // If it throws, that's also a way to prevent it + } + + const leakedPath = path.resolve(baseDir, "..", "leaked.json"); + expect(fs.existsSync(leakedPath)).toBe(false); + }); + + it("should prevent path traversal when reading", () => { + const maliciousKey = "../leaked"; + // Manually create a file outside + const leakedPath = path.resolve(baseDir, "..", "leaked.json"); + fs.writeFileSync(leakedPath, JSON.stringify({ secret: "internal" })); + + const data = store.read(maliciousKey); + expect(data).toBeUndefined(); + }); +}); diff --git a/workspace/users/{userId}/.jeanbot/context.md b/workspace/users/{userId}/.jeanbot/context.md deleted file mode 100644 index 207eb92..0000000 --- a/workspace/users/{userId}/.jeanbot/context.md +++ /dev/null @@ -1,7 +0,0 @@ -# JeanBot User Context - -- Current mission: Smoke test -- Updated at: 2026-03-13T21:07:03.733Z -- Completed steps: Inspect workspace files | Load and update memory context | Run policy and risk review | Decompose objective into steps | Create safety checkpoint | Handle finance-sensitive workflows | Synthesize final mission result | Track status and coordination | Synthesize final mission result | Clarify mission constraints | Produce mission documentation -- In-progress steps: none -- Upcoming steps: none \ No newline at end of file