From 08b80aff32b7b7ef34f8ef7b3ba86c31afed8078 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Mon, 23 Mar 2026 16:34:38 -0700 Subject: [PATCH] fix: use POSIX normalize for remote Linux paths in validateRemotePath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node:path.normalize() is platform-dependent — on Windows it converts forward slashes to backslashes, which then fail the character allowlist regex. Remote paths are always Linux paths regardless of the client OS. Switch to node:path/posix so normalization always uses forward slashes. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/__tests__/ssh-cov.test.ts | 39 ++++++++++++++++++++-- packages/cli/src/shared/ssh.ts | 2 +- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/__tests__/ssh-cov.test.ts b/packages/cli/src/__tests__/ssh-cov.test.ts index bf1c83371..953a8ac3d 100644 --- a/packages/cli/src/__tests__/ssh-cov.test.ts +++ b/packages/cli/src/__tests__/ssh-cov.test.ts @@ -13,9 +13,8 @@ import * as net from "node:net"; // Suppress stderr during tests — restored in afterAll to avoid contamination let stderrSpy: ReturnType; -const { spawnInteractive, startSshTunnel, waitForSsh, SSH_BASE_OPTS, SSH_INTERACTIVE_OPTS } = await import( - "../shared/ssh.js" -); +const { spawnInteractive, startSshTunnel, waitForSsh, validateRemotePath, SSH_BASE_OPTS, SSH_INTERACTIVE_OPTS } = + await import("../shared/ssh.js"); /** Create a fake socket (EventEmitter) that satisfies net.Socket interface for testing. */ function createFakeSocket(): net.Socket { @@ -274,6 +273,40 @@ describe("waitForSsh", () => { }); }); +// ── validateRemotePath ─────────────────────────────────────────────── + +describe("validateRemotePath", () => { + it("accepts valid Linux paths with forward slashes", () => { + expect(validateRemotePath("/home/user/config.json")).toBe("/home/user/config.json"); + expect(validateRemotePath("/root/.spawn-tarball")).toBe("/root/.spawn-tarball"); + expect(validateRemotePath("$HOME/.config/spawn")).toBe("$HOME/.config/spawn"); + }); + + it("normalizes using POSIX rules (no backslashes)", () => { + // normalize should collapse double slashes but never introduce backslashes + const result = validateRemotePath("/home//user///file.txt"); + expect(result).toBe("/home/user/file.txt"); + expect(result).not.toContain("\\"); + }); + + it("rejects path traversal", () => { + expect(() => validateRemotePath("/home/../etc/passwd")).toThrow("path traversal"); + expect(() => validateRemotePath("../etc/shadow")).toThrow("path traversal"); + }); + + it("rejects empty path", () => { + expect(() => validateRemotePath("")).toThrow("must not be empty"); + }); + + it("rejects argument injection", () => { + expect(() => validateRemotePath("/-evil")).toThrow('must not start with "-"'); + }); + + it("rejects unsafe characters", () => { + expect(() => validateRemotePath("/home/user;rm -rf")).toThrow("unsafe characters"); + }); +}); + // Final cleanup afterAll(() => { stderrSpy.mockRestore(); diff --git a/packages/cli/src/shared/ssh.ts b/packages/cli/src/shared/ssh.ts index 2f2cf3219..71fd5bbf2 100644 --- a/packages/cli/src/shared/ssh.ts +++ b/packages/cli/src/shared/ssh.ts @@ -2,7 +2,7 @@ import { spawnSync as nodeSpawnSync } from "node:child_process"; import { connect } from "node:net"; -import { normalize } from "node:path"; +import { normalize } from "node:path/posix"; import { asyncTryCatch, tryCatch } from "./result.js"; import { logError, logInfo, logStep, logStepDone, logStepInline } from "./ui.js";