fix: harden workspace boundary path resolution

This commit is contained in:
Peter Steinberger
2026-02-26 13:19:55 +01:00
parent ecb2053fdd
commit 46eba86b45
8 changed files with 767 additions and 177 deletions

View File

@@ -30,7 +30,8 @@ import { loadConfig, writeConfigFile } from "../../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
import { sameFileIdentity } from "../../infra/file-identity.js";
import { SafeOpenError, readLocalFileSafely } from "../../infra/fs-safe.js";
import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js";
import { assertNoPathAliasEscape } from "../../infra/path-alias-guards.js";
import { isNotFoundPathError } from "../../infra/path-guards.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
import { resolveUserPath } from "../../utils.js";
import {
@@ -143,8 +144,19 @@ async function resolveAgentWorkspaceFilePath(params: {
const requestPath = path.join(params.workspaceDir, params.name);
const workspaceReal = await resolveWorkspaceRealPath(params.workspaceDir);
const candidatePath = path.resolve(workspaceReal, params.name);
if (!isPathInside(workspaceReal, candidatePath)) {
return { kind: "invalid", requestPath, reason: "path escapes workspace root" };
try {
await assertNoPathAliasEscape({
absolutePath: candidatePath,
rootPath: workspaceReal,
boundaryLabel: "workspace root",
});
} catch (error) {
return {
kind: "invalid",
requestPath,
reason: error instanceof Error ? error.message : "path escapes workspace root",
};
}
let candidateLstat: Awaited<ReturnType<typeof fs.lstat>>;
@@ -169,27 +181,28 @@ async function resolveAgentWorkspaceFilePath(params: {
if (params.allowMissing) {
return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal };
}
return { kind: "invalid", requestPath, reason: "symlink target not found" };
return { kind: "invalid", requestPath, reason: "file not found" };
}
throw err;
}
if (!isPathInside(workspaceReal, targetReal)) {
return { kind: "invalid", requestPath, reason: "symlink target escapes workspace root" };
}
let targetStat: Awaited<ReturnType<typeof fs.stat>>;
try {
const targetStat = await fs.stat(targetReal);
if (!targetStat.isFile()) {
return { kind: "invalid", requestPath, reason: "symlink target is not a file" };
}
if (targetStat.nlink > 1) {
return { kind: "invalid", requestPath, reason: "hardlinked file target not allowed" };
}
targetStat = await fs.stat(targetReal);
} catch (err) {
if (isNotFoundPathError(err) && params.allowMissing) {
return { kind: "missing", requestPath, ioPath: targetReal, workspaceReal };
if (isNotFoundPathError(err)) {
if (params.allowMissing) {
return { kind: "missing", requestPath, ioPath: targetReal, workspaceReal };
}
return { kind: "invalid", requestPath, reason: "file not found" };
}
throw err;
}
if (!targetStat.isFile()) {
return { kind: "invalid", requestPath, reason: "path is not a regular file" };
}
if (targetStat.nlink > 1) {
return { kind: "invalid", requestPath, reason: "hardlinked file path not allowed" };
}
return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal };
}
@@ -200,11 +213,8 @@ async function resolveAgentWorkspaceFilePath(params: {
return { kind: "invalid", requestPath, reason: "hardlinked file path not allowed" };
}
const candidateReal = await fs.realpath(candidatePath).catch(() => candidatePath);
if (!isPathInside(workspaceReal, candidateReal)) {
return { kind: "invalid", requestPath, reason: "resolved file escapes workspace root" };
}
return { kind: "ready", requestPath, ioPath: candidateReal, workspaceReal };
const targetReal = await fs.realpath(candidatePath).catch(() => candidatePath);
return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal };
}
async function statFileSafely(filePath: string): Promise<FileMeta | null> {