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:
Aamer Akhter
2026-08-18 10:46:21 -04:00
parent 5080390e2c
commit 8e5691b05c
3 changed files with 105 additions and 6 deletions
+9 -3
View File
@@ -668,7 +668,9 @@ describe('file-routes', () => {
it('rejects path traversal attempts', async () => {
// realpathSync resolves the symlink to a path outside workingDir
mockedRealpathSync.mockReturnValue('/etc/passwd' as never);
mockedRealpathSync.mockImplementation(
(p: string) => (p === harness.ctx._session.workingDir ? p : '/etc/passwd') as never
);
const res = await harness.app.inject({
method: 'GET',
@@ -773,7 +775,9 @@ describe('file-routes', () => {
});
it('rejects path traversal in raw file serving', async () => {
mockedRealpathSync.mockReturnValue('/etc/shadow' as never);
mockedRealpathSync.mockImplementation(
(p: string) => (p === harness.ctx._session.workingDir ? p : '/etc/shadow') as never
);
const res = await harness.app.inject({
method: 'GET',
@@ -868,7 +872,9 @@ describe('file-routes', () => {
});
it('rejects symlink targets that escape the session working directory', async () => {
mockedRealpathSync.mockReturnValue('/tmp/outside-workdir/link.log' as never);
mockedRealpathSync.mockImplementation(
(p: string) => (p === harness.ctx._session.workingDir ? p : '/tmp/outside-workdir/link.log') as never
);
const res = await harness.app.inject({
method: 'GET',