fix: harden session transcript path resolution

This commit is contained in:
Peter Steinberger
2026-02-13 01:27:33 +01:00
parent 3eb6a31b6f
commit 4199f9889f
13 changed files with 322 additions and 66 deletions

View File

@@ -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);
});
});

View File

@@ -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 }) {

View File

@@ -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 });