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

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Security/Sandbox: confine mirrored skill sync destinations to the sandbox `skills/` root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal. - Security/Sandbox: confine mirrored skill sync destinations to the sandbox `skills/` root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.
- Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip `toolResult.details` from model-facing transcript/compaction inputs to reduce prompt-injection replay risk. - Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip `toolResult.details` from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.
- Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (`429` + `Retry-After`). Thanks @akhmittra. - Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (`429` + `Retry-After`). Thanks @akhmittra.
- Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.
- Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini. - Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.
- Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini. - Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini.
- Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini. - Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.

View File

@@ -6,6 +6,7 @@ import {
loadSessionStore, loadSessionStore,
resolveAgentIdFromSessionKey, resolveAgentIdFromSessionKey,
resolveMainSessionKey, resolveMainSessionKey,
resolveSessionFilePath,
resolveStorePath, resolveStorePath,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js"; import { callGateway } from "../gateway/call.js";
@@ -229,8 +230,16 @@ async function buildSubagentStatsLine(params: {
}); });
const sessionId = entry?.sessionId; const sessionId = entry?.sessionId;
const transcriptPath = let transcriptPath: string | undefined;
sessionId && storePath ? path.join(path.dirname(storePath), `${sessionId}.jsonl`) : undefined; if (sessionId && storePath) {
try {
transcriptPath = resolveSessionFilePath(sessionId, entry, {
sessionsDir: path.dirname(storePath),
});
} catch {
transcriptPath = undefined;
}
}
const input = entry?.inputTokens; const input = entry?.inputTokens;
const output = entry?.outputTokens; const output = entry?.outputTokens;

View File

@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
import path from "node:path"; import path from "node:path";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { resolveSessionFilePath } from "../../config/sessions.js";
import { callGateway } from "../../gateway/call.js"; import { callGateway } from "../../gateway/call.js";
import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { jsonResult, readStringArrayParam } from "./common.js"; import { jsonResult, readStringArrayParam } from "./common.js";
@@ -152,10 +153,20 @@ export function createSessionsListTool(opts?: {
}); });
const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined; const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined;
const transcriptPath = const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile;
sessionId && storePath const sessionFile = typeof sessionFileRaw === "string" ? sessionFileRaw : undefined;
? path.join(path.dirname(storePath), `${sessionId}.jsonl`) let transcriptPath: string | undefined;
: undefined; if (sessionId && storePath) {
try {
transcriptPath = resolveSessionFilePath(
sessionId,
sessionFile ? { sessionFile } : undefined,
{ sessionsDir: path.dirname(storePath) },
);
} catch {
transcriptPath = undefined;
}
}
const row: SessionListRow = { const row: SessionListRow = {
key: displayKey, key: displayKey,

View File

@@ -55,10 +55,12 @@ export type SessionInitResult = {
function forkSessionFromParent(params: { function forkSessionFromParent(params: {
parentEntry: SessionEntry; parentEntry: SessionEntry;
sessionsDir: string;
}): { sessionId: string; sessionFile: string } | null { }): { sessionId: string; sessionFile: string } | null {
const parentSessionFile = resolveSessionFilePath( const parentSessionFile = resolveSessionFilePath(
params.parentEntry.sessionId, params.parentEntry.sessionId,
params.parentEntry, params.parentEntry,
{ sessionsDir: params.sessionsDir },
); );
if (!parentSessionFile || !fs.existsSync(parentSessionFile)) { if (!parentSessionFile || !fs.existsSync(parentSessionFile)) {
return null; return null;
@@ -320,6 +322,7 @@ export async function initSessionState(params: {
); );
const forked = forkSessionFromParent({ const forked = forkSessionFromParent({
parentEntry: sessionStore[parentSessionKey], parentEntry: sessionStore[parentSessionKey],
sessionsDir: path.dirname(storePath),
}); });
if (forked) { if (forked) {
sessionId = forked.sessionId; sessionId = forked.sessionId;

View File

@@ -1,6 +1,12 @@
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveStorePath } from "./paths.js"; import {
resolveSessionFilePath,
resolveSessionTranscriptPath,
resolveSessionTranscriptPathInDir,
resolveStorePath,
validateSessionId,
} from "./paths.js";
describe("resolveStorePath", () => { describe("resolveStorePath", () => {
afterEach(() => { 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 os from "node:os";
import path from "node:path"; import path from "node:path";
import type { SessionEntry } from "./types.js";
import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js"; import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
import { resolveStateDir } from "../paths.js"; import { resolveStateDir } from "../paths.js";
@@ -34,11 +33,44 @@ export function resolveDefaultSessionStorePath(agentId?: string): string {
return path.join(resolveAgentSessionsDir(agentId), "sessions.json"); 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, sessionId: string,
agentId?: string, sessionsDir: string,
topicId?: string | number, topicId?: string | number,
): string { ): string {
const safeSessionId = validateSessionId(sessionId);
const safeTopicId = const safeTopicId =
typeof topicId === "string" typeof topicId === "string"
? encodeURIComponent(topicId) ? encodeURIComponent(topicId)
@@ -46,17 +78,31 @@ export function resolveSessionTranscriptPath(
? String(topicId) ? String(topicId)
: undefined; : undefined;
const fileName = const fileName =
safeTopicId !== undefined ? `${sessionId}-topic-${safeTopicId}.jsonl` : `${sessionId}.jsonl`; safeTopicId !== undefined
return path.join(resolveAgentSessionsDir(agentId), fileName); ? `${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( export function resolveSessionFilePath(
sessionId: string, sessionId: string,
entry?: SessionEntry, entry?: { sessionFile?: string },
opts?: { agentId?: string }, opts?: { agentId?: string; sessionsDir?: string },
): string { ): string {
const sessionsDir = resolveSessionsDir(opts);
const candidate = entry?.sessionFile?.trim(); 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 }) { 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 path from "node:path";
import type { SessionEntry } from "./types.js"; import type { SessionEntry } from "./types.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.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"; import { loadSessionStore, updateSessionStore } from "./store.js";
function stripQuery(value: string): string { function stripQuery(value: string): string {
@@ -103,8 +103,17 @@ export async function appendAssistantMessageToSessionTranscript(params: {
return { ok: false, reason: `unknown sessionKey: ${sessionKey}` }; return { ok: false, reason: `unknown sessionKey: ${sessionKey}` };
} }
const sessionFile = let sessionFile: string;
entry.sessionFile?.trim() || resolveSessionTranscriptPath(entry.sessionId, params.agentId); 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 }); await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId });

View File

@@ -30,7 +30,7 @@ describe("gateway chat.inject transcript writes", () => {
return { return {
...original, ...original,
loadSessionEntry: () => ({ loadSessionEntry: () => ({
storePath: "/tmp/store.json", storePath: path.join(dir, "sessions.json"),
entry: { entry: {
sessionId: "sess-1", sessionId: "sess-1",
sessionFile: transcriptPath, sessionFile: transcriptPath,

View File

@@ -9,6 +9,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import { resolveSessionFilePath } from "../../config/sessions.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { import {
@@ -54,13 +55,19 @@ function resolveTranscriptPath(params: {
sessionFile?: string; sessionFile?: string;
}): string | null { }): string | null {
const { sessionId, storePath, sessionFile } = params; const { sessionId, storePath, sessionFile } = params;
if (sessionFile) { if (!storePath && !sessionFile) {
return sessionFile; return null;
} }
if (!storePath) { try {
const sessionsDir = storePath ? path.dirname(storePath) : undefined;
return resolveSessionFilePath(
sessionId,
sessionFile ? { sessionFile } : undefined,
sessionsDir ? { sessionsDir } : undefined,
);
} catch {
return null; return null;
} }
return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
} }
function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): { function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): {

View File

@@ -107,40 +107,73 @@ describe("sessions.usage", () => {
it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => { it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => {
const storeKey = "agent:opus:slack:dm:u123"; const storeKey = "agent:opus:slack:dm:u123";
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-")); const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
const sessionFile = path.join(tempDir, "s-opus.jsonl"); const previousStateDir = process.env.OPENCLAW_STATE_DIR;
fs.writeFileSync(sessionFile, "", "utf-8"); process.env.OPENCLAW_STATE_DIR = stateDir;
try {
const agentSessionsDir = path.join(stateDir, "agents", "opus", "sessions");
fs.mkdirSync(agentSessionsDir, { recursive: true });
const sessionFile = path.join(agentSessionsDir, "s-opus.jsonl");
fs.writeFileSync(sessionFile, "", "utf-8");
const respond = vi.fn();
// Swap the store mock for this test: the canonical key differs from the discovered key
// but points at the same sessionId.
vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
storePath: "(multiple)",
store: {
[storeKey]: {
sessionId: "s-opus",
sessionFile: "s-opus.jsonl",
label: "Named session",
updatedAt: 999,
},
},
});
// Query via discovered key: agent:<id>:<sessionId>
await usageHandlers["sessions.usage"]({
respond,
params: {
startDate: "2026-02-01",
endDate: "2026-02-02",
key: "agent:opus:s-opus",
limit: 10,
},
} as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]);
expect(respond).toHaveBeenCalledTimes(1);
expect(respond.mock.calls[0]?.[0]).toBe(true);
const result = respond.mock.calls[0]?.[1] as unknown as { sessions: Array<{ key: string }> };
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]?.key).toBe(storeKey);
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("rejects traversal-style keys in specific session usage lookups", async () => {
const respond = vi.fn(); const respond = vi.fn();
// Swap the store mock for this test: the canonical key differs from the discovered key
// but points at the same sessionId.
vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
storePath: "(multiple)",
store: {
[storeKey]: {
sessionId: "s-opus",
sessionFile,
label: "Named session",
updatedAt: 999,
},
},
});
// Query via discovered key: agent:<id>:<sessionId>
await usageHandlers["sessions.usage"]({ await usageHandlers["sessions.usage"]({
respond, respond,
params: { params: {
startDate: "2026-02-01", startDate: "2026-02-01",
endDate: "2026-02-02", endDate: "2026-02-02",
key: "agent:opus:s-opus", key: "agent:opus:../../etc/passwd",
limit: 10, limit: 10,
}, },
} as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]); } as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]);
expect(respond).toHaveBeenCalledTimes(1); expect(respond).toHaveBeenCalledTimes(1);
expect(respond.mock.calls[0]?.[0]).toBe(true); expect(respond.mock.calls[0]?.[0]).toBe(false);
const result = respond.mock.calls[0]?.[1] as unknown as { sessions: Array<{ key: string }> }; const error = respond.mock.calls[0]?.[2] as { message?: string } | undefined;
expect(result.sessions).toHaveLength(1); expect(error?.message).toContain("Invalid session reference");
expect(result.sessions[0]?.key).toBe(storeKey);
}); });
}); });

View File

@@ -1,4 +1,5 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js"; import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
import type { import type {
CostUsageSummary, CostUsageSummary,
@@ -291,7 +292,7 @@ export const usageHandlers: GatewayRequestHandlers = {
const specificKey = typeof p.key === "string" ? p.key.trim() : null; const specificKey = typeof p.key === "string" ? p.key.trim() : null;
// Load session store for named sessions // Load session store for named sessions
const { store } = loadCombinedSessionStoreForGateway(config); const { storePath, store } = loadCombinedSessionStoreForGateway(config);
const now = Date.now(); const now = Date.now();
// Merge discovered sessions with store entries // Merge discovered sessions with store entries
@@ -331,9 +332,21 @@ export const usageHandlers: GatewayRequestHandlers = {
const sessionId = storeEntry?.sessionId ?? keyRest; const sessionId = storeEntry?.sessionId ?? keyRest;
// Resolve the session file path // Resolve the session file path
const sessionFile = resolveSessionFilePath(sessionId, storeEntry, { let sessionFile: string;
agentId: agentIdFromKey, try {
}); const pathOpts =
storePath && storePath !== "(multiple)"
? { sessionsDir: path.dirname(storePath) }
: { agentId: agentIdFromKey };
sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts);
} catch {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session reference: ${specificKey}`),
);
return;
}
try { try {
const stats = fs.statSync(sessionFile); const stats = fs.statSync(sessionFile);
@@ -756,15 +769,25 @@ export const usageHandlers: GatewayRequestHandlers = {
} }
const config = loadConfig(); const config = loadConfig();
const { entry } = loadSessionEntry(key); const { entry, storePath } = loadSessionEntry(key);
// For discovered sessions (not in store), try using key as sessionId directly // For discovered sessions (not in store), try using key as sessionId directly
const parsed = parseAgentSessionKey(key); const parsed = parseAgentSessionKey(key);
const agentId = parsed?.agentId; const agentId = parsed?.agentId;
const rawSessionId = parsed?.rest ?? key; const rawSessionId = parsed?.rest ?? key;
const sessionId = entry?.sessionId ?? rawSessionId; const sessionId = entry?.sessionId ?? rawSessionId;
const sessionFile = let sessionFile: string;
entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId }); try {
const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId };
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
} catch {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session key: ${key}`),
);
return;
}
const timeseries = await loadSessionUsageTimeSeries({ const timeseries = await loadSessionUsageTimeSeries({
sessionId, sessionId,
@@ -798,15 +821,25 @@ export const usageHandlers: GatewayRequestHandlers = {
: 200; : 200;
const config = loadConfig(); const config = loadConfig();
const { entry } = loadSessionEntry(key); const { entry, storePath } = loadSessionEntry(key);
// For discovered sessions (not in store), try using key as sessionId directly // For discovered sessions (not in store), try using key as sessionId directly
const parsed = parseAgentSessionKey(key); const parsed = parseAgentSessionKey(key);
const agentId = parsed?.agentId; const agentId = parsed?.agentId;
const rawSessionId = parsed?.rest ?? key; const rawSessionId = parsed?.rest ?? key;
const sessionId = entry?.sessionId ?? rawSessionId; const sessionId = entry?.sessionId ?? rawSessionId;
const sessionFile = let sessionFile: string;
entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId }); try {
const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId };
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
} catch {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session key: ${key}`),
);
return;
}
const { loadSessionLogs } = await import("../../infra/session-cost-usage.js"); const { loadSessionLogs } = await import("../../infra/session-cost-usage.js");
const logs = await loadSessionLogs({ const logs = await loadSessionLogs({

View File

@@ -507,3 +507,26 @@ describe("resolveSessionTranscriptCandidates", () => {
); );
}); });
}); });
describe("resolveSessionTranscriptCandidates safety", () => {
test("drops unsafe session IDs instead of producing traversal paths", () => {
const candidates = resolveSessionTranscriptCandidates(
"../etc/passwd",
"/tmp/openclaw/agents/main/sessions/sessions.json",
);
expect(candidates).toEqual([]);
});
test("drops unsafe sessionFile candidates and keeps safe fallbacks", () => {
const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json";
const candidates = resolveSessionTranscriptCandidates(
"sess-safe",
storePath,
"../../etc/passwd",
);
expect(candidates.some((value) => value.includes("etc/passwd"))).toBe(false);
expect(candidates).toContain(path.join(path.dirname(storePath), "sess-safe.jsonl"));
});
});

View File

@@ -2,7 +2,11 @@ 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 type { SessionPreviewItem } from "./session-utils.types.js"; import type { SessionPreviewItem } from "./session-utils.types.js";
import { resolveSessionTranscriptPath } from "../config/sessions.js"; import {
resolveSessionFilePath,
resolveSessionTranscriptPath,
resolveSessionTranscriptPathInDir,
} from "../config/sessions.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js"; import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
import { stripEnvelope } from "./chat-sanitize.js"; import { stripEnvelope } from "./chat-sanitize.js";
@@ -61,19 +65,40 @@ export function resolveSessionTranscriptCandidates(
agentId?: string, agentId?: string,
): string[] { ): string[] {
const candidates: string[] = []; const candidates: string[] = [];
if (sessionFile) { const pushCandidate = (resolve: () => string): void => {
candidates.push(sessionFile); try {
} candidates.push(resolve());
} catch {
// Ignore invalid paths/IDs and keep scanning other safe candidates.
}
};
if (storePath) { if (storePath) {
const dir = path.dirname(storePath); const sessionsDir = path.dirname(storePath);
candidates.push(path.join(dir, `${sessionId}.jsonl`)); if (sessionFile) {
pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir }));
}
pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir));
} else if (sessionFile) {
if (agentId) {
pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId }));
} else {
const trimmed = sessionFile.trim();
if (trimmed) {
candidates.push(path.resolve(trimmed));
}
}
} }
if (agentId) { if (agentId) {
candidates.push(resolveSessionTranscriptPath(sessionId, agentId)); pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId));
} }
const home = resolveRequiredHomeDir(process.env, os.homedir); const home = resolveRequiredHomeDir(process.env, os.homedir);
candidates.push(path.join(home, ".openclaw", "sessions", `${sessionId}.jsonl`)); const legacyDir = path.join(home, ".openclaw", "sessions");
return candidates; pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir));
return Array.from(new Set(candidates));
} }
export function archiveFileOnDisk(filePath: string, reason: string): string { export function archiveFileOnDisk(filePath: string, reason: string): string {