mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
fix(routes): canonicalize the workspace before comparing it to a resolved path
validateSessionFilePath realpath-resolves the candidate path but compared it against the raw sessionWorkingDir. When the workspace is itself reached through a symlink the two sides live in different namespaces, so relative() reports a spurious `../` and every file in that workspace is judged an escape — reads and writes in the session are refused wholesale. That is not an exotic setup: os.tmpdir() hands back a symlinked path on macOS (/tmp -> /private/tmp), and symlinked project directories and bind-mounted case paths hit it too. Resolve both sides and compare canonical to canonical. This only makes the comparison honest — it does not widen it. The candidate keeps its own realpath, so a symlink pointing out of the workspace and a ../ traversal are still refused, and a workspace that cannot be resolved now fails closed. Three stubs in file-routes.test.ts used a blanket realpathSync.mockReturnValue(escapeTarget), which answers the same path for the workspace and the candidate; with both sides resolved that makes an escape look contained. They now use the input-aware mockImplementation idiom the rest of that file already uses, so the workspace resolves to itself and only the candidate escapes. Verified they still bite: removing the confinement check turns all of them red. Adds test/route-helpers-symlink-confinement.test.ts, which exercises the function against a real symlinked workspace on disk and pins the negative cases (../ escape, symlink-out, missing file) alongside the fix.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user