VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.122026.2.12

Affected products

1

Patches

2
25950bcbb8ba

fix(sessions): normalize absolute sessionFile paths for v2026.2.12 compatibility

https://github.com/openclaw/openclawIon MudreacFeb 13, 2026via ghsa
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(
    
4199f9889f0c

fix: harden session transcript path resolution

https://github.com/openclaw/openclawPeter SteinbergerFeb 13, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.