Merge pull request #314 from aakhter/fix/symlink-safe-workspace-confinement

fix(routes): canonicalize the workspace before comparing it to a resolved path
This commit is contained in:
Ark0N
2026-08-18 21:15:11 +02:00
committed by GitHub
3 changed files with 105 additions and 6 deletions
+13 -3
View File
@@ -63,19 +63,29 @@ export async function readJsonConfig<T>(filePath: string, logLabel: string, defa
* Validates that a file path (possibly containing symlinks) resolves to a location
* within the given session working directory. Returns the resolved and relative paths,
* or null if the path escapes the directory or doesn't exist.
*
* BOTH sides are realpath-resolved before they are compared. Resolving only the
* candidate leaves the two paths in different namespaces whenever the workspace
* itself is reached through a symlink, and `relative()` then reports a spurious
* `../` for a file that is genuinely inside it — refusing every read and write in
* that session. A symlinked workspace is ordinary: `os.tmpdir()` returns one on
* macOS (`/tmp` -> `/private/tmp`), as do symlinked project dirs and bind-mounted
* case paths. Canonicalizing the base only makes the comparison honest; escapes
* are still refused, since the candidate keeps its own realpath.
*/
export function validateSessionFilePath(
sessionWorkingDir: string,
filePath: string
): { resolvedPath: string; relativePath: string } | null {
const fullPath = resolve(sessionWorkingDir, filePath);
let resolvedWorkingDir: string;
let resolvedPath: string;
try {
resolvedPath = realpathSync(fullPath);
resolvedWorkingDir = realpathSync(sessionWorkingDir);
resolvedPath = realpathSync(resolve(sessionWorkingDir, filePath));
} catch {
return null;
}
const relativePath = relative(sessionWorkingDir, resolvedPath);
const relativePath = relative(resolvedWorkingDir, resolvedPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return null;
}