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

View File

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

View File

@@ -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,

View File

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

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

View File

@@ -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,

View File

@@ -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) {
if (!storePath && !sessionFile) {
return null;
}
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 }): {

View File

@@ -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 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: "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();
// 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"]({
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");
});
});

View File

@@ -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({

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 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 {