mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:11:36 +00:00
fix: harden session transcript path resolution
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveStorePath } from "./paths.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionTranscriptPath,
|
||||
resolveSessionTranscriptPathInDir,
|
||||
resolveStorePath,
|
||||
validateSessionId,
|
||||
} from "./paths.js";
|
||||
|
||||
describe("resolveStorePath", () => {
|
||||
afterEach(() => {
|
||||
@@ -20,3 +26,53 @@ describe("resolveStorePath", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session path safety", () => {
|
||||
it("validates safe session IDs", () => {
|
||||
expect(validateSessionId("sess-1")).toBe("sess-1");
|
||||
expect(validateSessionId("ABC_123.hello")).toBe("ABC_123.hello");
|
||||
});
|
||||
|
||||
it("rejects unsafe session IDs", () => {
|
||||
expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/);
|
||||
expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/);
|
||||
expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/);
|
||||
expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/);
|
||||
});
|
||||
|
||||
it("resolves transcript path inside an explicit sessions dir", () => {
|
||||
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
||||
const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b");
|
||||
|
||||
expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl"));
|
||||
});
|
||||
|
||||
it("rejects unsafe sessionFile candidates that escape the sessions dir", () => {
|
||||
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
||||
|
||||
expect(() =>
|
||||
resolveSessionFilePath("sess-1", { sessionFile: "../../etc/passwd" }, { sessionsDir }),
|
||||
).toThrow(/within sessions directory/);
|
||||
|
||||
expect(() =>
|
||||
resolveSessionFilePath("sess-1", { sessionFile: "/etc/passwd" }, { sessionsDir }),
|
||||
).toThrow(/within sessions directory/);
|
||||
});
|
||||
|
||||
it("accepts sessionFile candidates within the sessions dir", () => {
|
||||
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
||||
|
||||
const resolved = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: "subdir/threaded-session.jsonl" },
|
||||
{ sessionsDir },
|
||||
);
|
||||
|
||||
expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl"));
|
||||
});
|
||||
|
||||
it("uses agent sessions dir fallback for transcript path", () => {
|
||||
const resolved = resolveSessionTranscriptPath("sess-1", "main");
|
||||
expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { resolveStateDir } from "../paths.js";
|
||||
@@ -34,11 +33,44 @@ export function resolveDefaultSessionStorePath(agentId?: string): string {
|
||||
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptPath(
|
||||
export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
|
||||
|
||||
export function validateSessionId(sessionId: string): string {
|
||||
const trimmed = sessionId.trim();
|
||||
if (!SAFE_SESSION_ID_RE.test(trimmed)) {
|
||||
throw new Error(`Invalid session ID: ${sessionId}`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveSessionsDir(opts?: { agentId?: string; sessionsDir?: string }): string {
|
||||
const sessionsDir = opts?.sessionsDir?.trim();
|
||||
if (sessionsDir) {
|
||||
return path.resolve(sessionsDir);
|
||||
}
|
||||
return resolveAgentSessionsDir(opts?.agentId);
|
||||
}
|
||||
|
||||
function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): string {
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Session file path must not be empty");
|
||||
}
|
||||
const resolvedBase = path.resolve(sessionsDir);
|
||||
const resolvedCandidate = path.resolve(resolvedBase, trimmed);
|
||||
const relative = path.relative(resolvedBase, resolvedCandidate);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error("Session file path must be within sessions directory");
|
||||
}
|
||||
return resolvedCandidate;
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptPathInDir(
|
||||
sessionId: string,
|
||||
agentId?: string,
|
||||
sessionsDir: string,
|
||||
topicId?: string | number,
|
||||
): string {
|
||||
const safeSessionId = validateSessionId(sessionId);
|
||||
const safeTopicId =
|
||||
typeof topicId === "string"
|
||||
? encodeURIComponent(topicId)
|
||||
@@ -46,17 +78,31 @@ export function resolveSessionTranscriptPath(
|
||||
? String(topicId)
|
||||
: undefined;
|
||||
const fileName =
|
||||
safeTopicId !== undefined ? `${sessionId}-topic-${safeTopicId}.jsonl` : `${sessionId}.jsonl`;
|
||||
return path.join(resolveAgentSessionsDir(agentId), fileName);
|
||||
safeTopicId !== undefined
|
||||
? `${safeSessionId}-topic-${safeTopicId}.jsonl`
|
||||
: `${safeSessionId}.jsonl`;
|
||||
return resolvePathWithinSessionsDir(sessionsDir, fileName);
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptPath(
|
||||
sessionId: string,
|
||||
agentId?: string,
|
||||
topicId?: string | number,
|
||||
): string {
|
||||
return resolveSessionTranscriptPathInDir(sessionId, resolveAgentSessionsDir(agentId), topicId);
|
||||
}
|
||||
|
||||
export function resolveSessionFilePath(
|
||||
sessionId: string,
|
||||
entry?: SessionEntry,
|
||||
opts?: { agentId?: string },
|
||||
entry?: { sessionFile?: string },
|
||||
opts?: { agentId?: string; sessionsDir?: string },
|
||||
): string {
|
||||
const sessionsDir = resolveSessionsDir(opts);
|
||||
const candidate = entry?.sessionFile?.trim();
|
||||
return candidate ? candidate : resolveSessionTranscriptPath(sessionId, opts?.agentId);
|
||||
if (candidate) {
|
||||
return resolvePathWithinSessionsDir(sessionsDir, candidate);
|
||||
}
|
||||
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
|
||||
}
|
||||
|
||||
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
|
||||
import { resolveDefaultSessionStorePath, resolveSessionTranscriptPath } from "./paths.js";
|
||||
import { resolveDefaultSessionStorePath, resolveSessionFilePath } from "./paths.js";
|
||||
import { loadSessionStore, updateSessionStore } from "./store.js";
|
||||
|
||||
function stripQuery(value: string): string {
|
||||
@@ -103,8 +103,17 @@ export async function appendAssistantMessageToSessionTranscript(params: {
|
||||
return { ok: false, reason: `unknown sessionKey: ${sessionKey}` };
|
||||
}
|
||||
|
||||
const sessionFile =
|
||||
entry.sessionFile?.trim() || resolveSessionTranscriptPath(entry.sessionId, params.agentId);
|
||||
let sessionFile: string;
|
||||
try {
|
||||
sessionFile = resolveSessionFilePath(entry.sessionId, entry, {
|
||||
sessionsDir: path.dirname(storePath),
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user