High severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026
OpenClaw < 2026.2.12 - Arbitrary File Write via Untrusted sessionFile Path
CVE-2026-28459
Description
OpenClaw versions prior to 2026.2.12 fail to validate the sessionFile path parameter, allowing authenticated gateway clients to write transcript data to arbitrary locations on the host filesystem. Attackers can supply a sessionFile path outside the sessions directory to create files and append data repeatedly, potentially causing configuration corruption or denial of service.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.12 | 2026.2.12 |
Affected products
1Patches
225950bcbb8bafix(sessions): normalize absolute sessionFile paths for v2026.2.12 compatibility
2 files changed · +42 −4
src/config/sessions/paths.test.ts+36 −0 modified@@ -72,6 +72,42 @@ describe("session path safety", () => { expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl")); }); + it("accepts absolute sessionFile paths that resolve within the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123.jsonl" }, + { sessionsDir }, + ); + + expect(resolved).toBe(path.resolve(sessionsDir, "abc-123.jsonl")); + }); + + it("accepts absolute sessionFile with topic suffix within the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123-topic-42.jsonl" }, + { sessionsDir }, + ); + + expect(resolved).toBe(path.resolve(sessionsDir, "abc-123-topic-42.jsonl")); + }); + + it("rejects absolute sessionFile paths outside the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + expect(() => + resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/work/sessions/abc-123.jsonl" }, + { sessionsDir }, + ), + ).toThrow(/within sessions directory/); + }); + 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);
src/config/sessions/paths.ts+6 −4 modified@@ -77,12 +77,14 @@ function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): s 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)) { + // Normalize absolute paths that are within the sessions directory. + // Older versions stored absolute sessionFile paths in sessions.json; + // convert them to relative so the containment check passes. + const normalized = path.isAbsolute(trimmed) ? path.relative(resolvedBase, trimmed) : trimmed; + if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) { throw new Error("Session file path must be within sessions directory"); } - return resolvedCandidate; + return path.resolve(resolvedBase, normalized); } export function resolveSessionTranscriptPathInDir(
4199f9889f0cfix: harden session transcript path resolution
13 files changed · +320 −64
CHANGELOG.md+1 −0 modified@@ -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/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. +- 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. - 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.
src/agents/subagent-announce.ts+11 −2 modified@@ -6,6 +6,7 @@ import { loadSessionStore, resolveAgentIdFromSessionKey, resolveMainSessionKey, + resolveSessionFilePath, resolveStorePath, } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; @@ -229,8 +230,16 @@ async function buildSubagentStatsLine(params: { }); const sessionId = entry?.sessionId; - const transcriptPath = - sessionId && storePath ? path.join(path.dirname(storePath), `${sessionId}.jsonl`) : undefined; + let transcriptPath: string | undefined; + if (sessionId && storePath) { + try { + transcriptPath = resolveSessionFilePath(sessionId, entry, { + sessionsDir: path.dirname(storePath), + }); + } catch { + transcriptPath = undefined; + } + } const input = entry?.inputTokens; const output = entry?.outputTokens;
src/agents/tools/sessions-list-tool.ts+15 −4 modified@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import path from "node:path"; import type { AnyAgentTool } from "./common.js"; import { loadConfig } from "../../config/config.js"; +import { resolveSessionFilePath } from "../../config/sessions.js"; import { callGateway } from "../../gateway/call.js"; import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.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 transcriptPath = - sessionId && storePath - ? path.join(path.dirname(storePath), `${sessionId}.jsonl`) - : undefined; + const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile; + const sessionFile = typeof sessionFileRaw === "string" ? sessionFileRaw : undefined; + let transcriptPath: string | undefined; + if (sessionId && storePath) { + try { + transcriptPath = resolveSessionFilePath( + sessionId, + sessionFile ? { sessionFile } : undefined, + { sessionsDir: path.dirname(storePath) }, + ); + } catch { + transcriptPath = undefined; + } + } const row: SessionListRow = { key: displayKey,
src/auto-reply/reply/session.ts+3 −0 modified@@ -55,10 +55,12 @@ export type SessionInitResult = { function forkSessionFromParent(params: { parentEntry: SessionEntry; + sessionsDir: string; }): { sessionId: string; sessionFile: string } | null { const parentSessionFile = resolveSessionFilePath( params.parentEntry.sessionId, params.parentEntry, + { sessionsDir: params.sessionsDir }, ); if (!parentSessionFile || !fs.existsSync(parentSessionFile)) { return null; @@ -320,6 +322,7 @@ export async function initSessionState(params: { ); const forked = forkSessionFromParent({ parentEntry: sessionStore[parentSessionKey], + sessionsDir: path.dirname(storePath), }); if (forked) { sessionId = forked.sessionId;
src/config/sessions/paths.test.ts+57 −1 modified@@ -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); + }); +});
src/config/sessions/paths.ts+54 −8 modified@@ -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,29 +33,76 @@ 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) : typeof topicId === "number" ? 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 }) {
src/config/sessions/transcript.ts+12 −3 modified@@ -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 });
src/gateway/server-methods/chat.inject.parentid.test.ts+1 −1 modified@@ -30,7 +30,7 @@ describe("gateway chat.inject transcript writes", () => { return { ...original, loadSessionEntry: () => ({ - storePath: "/tmp/store.json", + storePath: path.join(dir, "sessions.json"), entry: { sessionId: "sess-1", sessionFile: transcriptPath,
src/gateway/server-methods/chat.ts+11 −4 modified@@ -9,6 +9,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; +import { resolveSessionFilePath } from "../../config/sessions.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { @@ -54,13 +55,19 @@ function resolveTranscriptPath(params: { sessionFile?: string; }): string | null { const { sessionId, storePath, sessionFile } = params; - if (sessionFile) { - return sessionFile; + if (!storePath && !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 path.join(path.dirname(storePath), `${sessionId}.jsonl`); } function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): {
src/gateway/server-methods/usage.sessions-usage.test.ts+55 −22 modified@@ -107,40 +107,73 @@ describe("sessions.usage", () => { it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => { const storeKey = "agent:opus:slack:dm:u123"; - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-")); - const sessionFile = path.join(tempDir, "s-opus.jsonl"); - fs.writeFileSync(sessionFile, "", "utf-8"); - const respond = vi.fn(); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-")); + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + 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, - label: "Named session", - updatedAt: 999, + // 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(); - // 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", + key: "agent:opus:../../etc/passwd", 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); + expect(respond.mock.calls[0]?.[0]).toBe(false); + const error = respond.mock.calls[0]?.[2] as { message?: string } | undefined; + expect(error?.message).toContain("Invalid session reference"); }); });
src/gateway/server-methods/usage.ts+43 −10 modified@@ -1,4 +1,5 @@ import fs from "node:fs"; +import path from "node:path"; import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js"; import type { CostUsageSummary, @@ -291,7 +292,7 @@ export const usageHandlers: GatewayRequestHandlers = { const specificKey = typeof p.key === "string" ? p.key.trim() : null; // Load session store for named sessions - const { store } = loadCombinedSessionStoreForGateway(config); + const { storePath, store } = loadCombinedSessionStoreForGateway(config); const now = Date.now(); // Merge discovered sessions with store entries @@ -331,9 +332,21 @@ export const usageHandlers: GatewayRequestHandlers = { const sessionId = storeEntry?.sessionId ?? keyRest; // Resolve the session file path - const sessionFile = resolveSessionFilePath(sessionId, storeEntry, { - agentId: agentIdFromKey, - }); + let sessionFile: string; + 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 { const stats = fs.statSync(sessionFile); @@ -756,15 +769,25 @@ export const usageHandlers: GatewayRequestHandlers = { } const config = loadConfig(); - const { entry } = loadSessionEntry(key); + const { entry, storePath } = loadSessionEntry(key); // For discovered sessions (not in store), try using key as sessionId directly const parsed = parseAgentSessionKey(key); const agentId = parsed?.agentId; const rawSessionId = parsed?.rest ?? key; const sessionId = entry?.sessionId ?? rawSessionId; - const sessionFile = - entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId }); + let sessionFile: string; + 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({ sessionId, @@ -798,15 +821,25 @@ export const usageHandlers: GatewayRequestHandlers = { : 200; const config = loadConfig(); - const { entry } = loadSessionEntry(key); + const { entry, storePath } = loadSessionEntry(key); // For discovered sessions (not in store), try using key as sessionId directly const parsed = parseAgentSessionKey(key); const agentId = parsed?.agentId; const rawSessionId = parsed?.rest ?? key; const sessionId = entry?.sessionId ?? rawSessionId; - const sessionFile = - entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId }); + let sessionFile: string; + 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 logs = await loadSessionLogs({
src/gateway/session-utils.fs.test.ts+23 −0 modified@@ -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")); + }); +});
src/gateway/session-utils.fs.ts+34 −9 modified@@ -2,7 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; 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 { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js"; import { stripEnvelope } from "./chat-sanitize.js"; @@ -61,19 +65,40 @@ export function resolveSessionTranscriptCandidates( agentId?: string, ): string[] { const candidates: string[] = []; - if (sessionFile) { - candidates.push(sessionFile); - } + const pushCandidate = (resolve: () => string): void => { + try { + candidates.push(resolve()); + } catch { + // Ignore invalid paths/IDs and keep scanning other safe candidates. + } + }; + if (storePath) { - const dir = path.dirname(storePath); - candidates.push(path.join(dir, `${sessionId}.jsonl`)); + const sessionsDir = path.dirname(storePath); + 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) { - candidates.push(resolveSessionTranscriptPath(sessionId, agentId)); + pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId)); } + const home = resolveRequiredHomeDir(process.env, os.homedir); - candidates.push(path.join(home, ".openclaw", "sessions", `${sessionId}.jsonl`)); - return candidates; + const legacyDir = path.join(home, ".openclaw", "sessions"); + pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir)); + + return Array.from(new Set(candidates)); } export function archiveFileOnDisk(filePath: string, reason: string): string {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/openclaw/openclaw/commit/25950bcbb8ba4d8cde002557f6e27c219ae4dedaghsapatchWEB
- github.com/openclaw/openclaw/commit/4199f9889f0c307b77096a229b9e085b8d856c26ghsapatchWEB
- github.com/advisories/GHSA-64qx-vpxx-mvqfghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-64qx-vpxx-mvqfghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-28459ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-arbitrary-file-write-via-untrusted-sessionfile-pathghsathird-party-advisoryWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.12ghsaWEB
News mentions
0No linked articles in our index yet.