fix(security): harden structural session path fallback

This commit is contained in:
Peter Steinberger
2026-02-24 02:52:25 +00:00
parent ff4e6ca0d9
commit fefc414576
2 changed files with 79 additions and 5 deletions

View File

@@ -561,6 +561,43 @@ describe("sessions", () => {
});
});
it("falls back when structural cross-root path traverses after sessions", () => {
withStateDir(path.resolve("/different/state"), () => {
const originalBase = path.resolve("/original/state");
const unsafe = path.join(originalBase, "agents", "bot2", "sessions", "..", "..", "etc");
const sessionFile = resolveSessionFilePath(
"sess-1",
{ sessionFile: path.join(unsafe, "passwd") },
{ agentId: "bot1" },
);
expect(sessionFile).toBe(
path.join(path.resolve("/different/state"), "agents", "bot1", "sessions", "sess-1.jsonl"),
);
});
});
it("falls back when structural cross-root path nests under sessions", () => {
withStateDir(path.resolve("/different/state"), () => {
const originalBase = path.resolve("/original/state");
const nested = path.join(
originalBase,
"agents",
"bot2",
"sessions",
"nested",
"sess-1.jsonl",
);
const sessionFile = resolveSessionFilePath(
"sess-1",
{ sessionFile: nested },
{ agentId: "bot1" },
);
expect(sessionFile).toBe(
path.join(path.resolve("/different/state"), "agents", "bot1", "sessions", "sess-1.jsonl"),
);
});
});
it("falls back to derived transcript path when sessionFile is outside agent sessions directories", () => {
withStateDir(path.resolve("/home/user/.openclaw"), () => {
const sessionFile = resolveSessionFilePath(

View File

@@ -115,6 +115,39 @@ function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string
return agentId || undefined;
}
function resolveStructuralSessionFallbackPath(
candidateAbsPath: string,
expectedAgentId: string,
): string | undefined {
const normalized = path.normalize(path.resolve(candidateAbsPath));
const parts = normalized.split(path.sep).filter(Boolean);
const sessionsIndex = parts.lastIndexOf("sessions");
if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") {
return undefined;
}
const agentIdPart = parts[sessionsIndex - 1];
if (!agentIdPart) {
return undefined;
}
const normalizedAgentId = normalizeAgentId(agentIdPart);
if (normalizedAgentId !== agentIdPart.toLowerCase()) {
return undefined;
}
if (normalizedAgentId !== normalizeAgentId(expectedAgentId)) {
return undefined;
}
const relativeSegments = parts.slice(sessionsIndex + 1);
// Session transcripts are stored as direct files in "sessions/".
if (relativeSegments.length !== 1) {
return undefined;
}
const fileName = relativeSegments[0];
if (!fileName || fileName === "." || fileName === "..") {
return undefined;
}
return normalized;
}
function safeRealpathSync(filePath: string): string | undefined {
try {
return fs.realpathSync(filePath);
@@ -170,11 +203,15 @@ function resolvePathWithinSessionsDir(
if (resolvedFromPath) {
return resolvedFromPath;
}
// The path structurally matches .../agents/<agentId>/sessions/...
// 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(realTrimmed);
// Cross-root compatibility for older absolute paths:
// keep only canonical .../agents/<agentId>/sessions/<file> shapes.
const structuralFallback = resolveStructuralSessionFallbackPath(
realTrimmed,
extractedAgentId,
);
if (structuralFallback) {
return structuralFallback;
}
}
}
if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) {