mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 17:34:35 +00:00
Sessions: consolidate path hardening and fallback resilience (#24657)
* Changelog: credit session path fixes * Sessions: harden path resolution for symlink and stale metadata * Tests: cover fallback for invalid absolute sessionFile * Tests: add symlink alias session path coverage * Tests: guard symlink escape in sessionFile resolution
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
||||
@@ -76,8 +77,10 @@ function resolvePathFromAgentSessionsDir(
|
||||
agentSessionsDir: string,
|
||||
candidateAbsPath: string,
|
||||
): string | undefined {
|
||||
const agentBase = path.resolve(agentSessionsDir);
|
||||
const relative = path.relative(agentBase, candidateAbsPath);
|
||||
const agentBase =
|
||||
safeRealpathSync(path.resolve(agentSessionsDir)) ?? path.resolve(agentSessionsDir);
|
||||
const realCandidate = safeRealpathSync(candidateAbsPath) ?? candidateAbsPath;
|
||||
const relative = path.relative(agentBase, realCandidate);
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -112,6 +115,14 @@ function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string
|
||||
return agentId || undefined;
|
||||
}
|
||||
|
||||
function safeRealpathSync(filePath: string): string | undefined {
|
||||
try {
|
||||
return fs.realpathSync(filePath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePathWithinSessionsDir(
|
||||
sessionsDir: string,
|
||||
candidate: string,
|
||||
@@ -122,21 +133,28 @@ function resolvePathWithinSessionsDir(
|
||||
throw new Error("Session file path must not be empty");
|
||||
}
|
||||
const resolvedBase = path.resolve(sessionsDir);
|
||||
const realBase = safeRealpathSync(resolvedBase) ?? resolvedBase;
|
||||
// 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.startsWith("..") && path.isAbsolute(trimmed)) {
|
||||
const realTrimmed = path.isAbsolute(trimmed) ? (safeRealpathSync(trimmed) ?? trimmed) : trimmed;
|
||||
const normalized = path.isAbsolute(realTrimmed)
|
||||
? path.relative(realBase, realTrimmed)
|
||||
: realTrimmed;
|
||||
if (normalized.startsWith("..") && path.isAbsolute(realTrimmed)) {
|
||||
const tryAgentFallback = (agentId: string): string | undefined => {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const siblingSessionsDir = resolveSiblingAgentSessionsDir(resolvedBase, normalizedAgentId);
|
||||
const siblingSessionsDir = resolveSiblingAgentSessionsDir(realBase, normalizedAgentId);
|
||||
if (siblingSessionsDir) {
|
||||
const siblingResolved = resolvePathFromAgentSessionsDir(siblingSessionsDir, trimmed);
|
||||
const siblingResolved = resolvePathFromAgentSessionsDir(siblingSessionsDir, realTrimmed);
|
||||
if (siblingResolved) {
|
||||
return siblingResolved;
|
||||
}
|
||||
}
|
||||
return resolvePathFromAgentSessionsDir(resolveAgentSessionsDir(normalizedAgentId), trimmed);
|
||||
return resolvePathFromAgentSessionsDir(
|
||||
resolveAgentSessionsDir(normalizedAgentId),
|
||||
realTrimmed,
|
||||
);
|
||||
};
|
||||
|
||||
const explicitAgentId = opts?.agentId?.trim();
|
||||
@@ -146,7 +164,7 @@ function resolvePathWithinSessionsDir(
|
||||
return resolvedFromAgent;
|
||||
}
|
||||
}
|
||||
const extractedAgentId = extractAgentIdFromAbsoluteSessionPath(trimmed);
|
||||
const extractedAgentId = extractAgentIdFromAbsoluteSessionPath(realTrimmed);
|
||||
if (extractedAgentId) {
|
||||
const resolvedFromPath = tryAgentFallback(extractedAgentId);
|
||||
if (resolvedFromPath) {
|
||||
@@ -156,13 +174,13 @@ function resolvePathWithinSessionsDir(
|
||||
// Accept it even if the root directory differs from the current env
|
||||
// (e.g., OPENCLAW_STATE_DIR changed between session creation and resolution).
|
||||
// The structural pattern provides sufficient containment guarantees.
|
||||
return path.resolve(trimmed);
|
||||
return path.resolve(realTrimmed);
|
||||
}
|
||||
}
|
||||
if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) {
|
||||
throw new Error("Session file path must be within sessions directory");
|
||||
}
|
||||
return path.resolve(resolvedBase, normalized);
|
||||
return path.resolve(realBase, normalized);
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptPathInDir(
|
||||
@@ -200,7 +218,11 @@ export function resolveSessionFilePath(
|
||||
const sessionsDir = resolveSessionsDir(opts);
|
||||
const candidate = entry?.sessionFile?.trim();
|
||||
if (candidate) {
|
||||
return resolvePathWithinSessionsDir(sessionsDir, candidate, { agentId: opts?.agentId });
|
||||
try {
|
||||
return resolvePathWithinSessionsDir(sessionsDir, candidate, { agentId: opts?.agentId });
|
||||
} catch {
|
||||
// Keep handlers alive when persisted metadata is stale/corrupt.
|
||||
}
|
||||
}
|
||||
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
|
||||
}
|
||||
|
||||
@@ -56,16 +56,64 @@ describe("session path safety", () => {
|
||||
expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl"));
|
||||
});
|
||||
|
||||
it("rejects absolute sessionFile paths outside known agent sessions dirs", () => {
|
||||
it("falls back to derived path when sessionFile is outside known agent sessions dirs", () => {
|
||||
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
||||
|
||||
expect(() =>
|
||||
resolveSessionFilePath(
|
||||
const resolved = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" },
|
||||
{ sessionsDir },
|
||||
);
|
||||
expect(resolved).toBe(path.resolve(sessionsDir, "sess-1.jsonl"));
|
||||
});
|
||||
|
||||
it("accepts symlink-alias session paths that resolve under the sessions dir", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-symlink-session-"));
|
||||
const realRoot = path.join(tmpDir, "real-state");
|
||||
const aliasRoot = path.join(tmpDir, "alias-state");
|
||||
try {
|
||||
const sessionsDir = path.join(realRoot, "agents", "main", "sessions");
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.symlinkSync(realRoot, aliasRoot, "dir");
|
||||
const viaAlias = path.join(aliasRoot, "agents", "main", "sessions", "sess-1.jsonl");
|
||||
fs.writeFileSync(path.join(sessionsDir, "sess-1.jsonl"), "");
|
||||
const resolved = resolveSessionFilePath("sess-1", { sessionFile: viaAlias }, { sessionsDir });
|
||||
expect(fs.realpathSync(resolved)).toBe(
|
||||
fs.realpathSync(path.join(sessionsDir, "sess-1.jsonl")),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back when sessionFile is a symlink that escapes sessions dir", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-symlink-escape-"));
|
||||
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
|
||||
const outsideDir = path.join(tmpDir, "outside");
|
||||
try {
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
const outsideFile = path.join(outsideDir, "escaped.jsonl");
|
||||
fs.writeFileSync(outsideFile, "");
|
||||
const symlinkPath = path.join(sessionsDir, "escaped.jsonl");
|
||||
fs.symlinkSync(outsideFile, symlinkPath, "file");
|
||||
|
||||
const resolved = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" },
|
||||
{ sessionFile: symlinkPath },
|
||||
{ sessionsDir },
|
||||
),
|
||||
).toThrow(/within sessions directory/);
|
||||
);
|
||||
expect(fs.realpathSync(path.dirname(resolved))).toBe(fs.realpathSync(sessionsDir));
|
||||
expect(path.basename(resolved)).toBe("sess-1.jsonl");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user