From 2aba8e655248c86f24c8380e2bf1ceee97f76840 Mon Sep 17 00:00:00 2001 From: B <6723574+louisgv@users.noreply.github.com> Date: Sat, 28 Mar 2026 06:31:04 +0000 Subject: [PATCH] fix(security): expand $HOME before path validation in downloadFile Fixes #3080 Prevents path traversal via other $VAR expansions by normalizing $HOME to ~ before the strict path regex check, removing the need to allow $ in the charset. Applied to all 5 cloud providers: - digitalocean: downloadFile - aws: downloadFile - sprite: downloadFileSprite - gcp: uploadFile + downloadFile - hetzner: downloadFile Also bumps CLI version to 0.27.7. Agent: security-auditor Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/package.json | 2 +- packages/cli/src/aws/aws.ts | 6 +++--- packages/cli/src/digitalocean/digitalocean.ts | 6 +++--- packages/cli/src/gcp/gcp.ts | 13 ++++++------- packages/cli/src/hetzner/hetzner.ts | 6 +++--- packages/cli/src/sprite/sprite.ts | 6 +++--- 6 files changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index be1e999f9..c6092d7d8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.27.6", + "version": "0.27.7", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index 2d96ec1dd..accea4a66 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -1178,15 +1178,15 @@ export async function uploadFile(localPath: string, remotePath: string): Promise } export async function downloadFile(remotePath: string, localPath: string): Promise { - const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); + const expandedRemote = remotePath.replace(/^\$HOME\//, "~/"); + const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/); const keyOpts = getSshKeyOpts(await ensureSshKeys()); - const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const proc = Bun.spawn( [ "scp", ...SSH_BASE_OPTS, ...keyOpts, - `${SSH_USER}@${_state.instanceIp}:${expandedPath}`, + `${SSH_USER}@${_state.instanceIp}:${normalizedRemote}`, localPath, ], { diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 638114b99..ddd97d7d8 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -1448,17 +1448,17 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str export async function downloadFile(remotePath: string, localPath: string, ip?: string): Promise { const serverIp = ip || _state.serverIp; - const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); + const expandedRemote = remotePath.replace(/^\$HOME\//, "~/"); + const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/); const keyOpts = getSshKeyOpts(await ensureSshKeys()); - const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const proc = Bun.spawn( [ "scp", ...SSH_BASE_OPTS, ...keyOpts, - `root@${serverIp}:${expandedPath}`, + `root@${serverIp}:${normalizedRemote}`, localPath, ], { diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index 4feef7e60..6d563177b 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -1028,10 +1028,9 @@ export async function uploadFile(localPath: string, remotePath: string): Promise logError(`Invalid local path: ${localPath}`); throw new Error("Invalid local path"); } - const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); + const expandedRemote = remotePath.replace(/^\$HOME\//, "~/"); + const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/); const username = resolveUsername(); - // Expand $HOME on remote side - const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const keyOpts = getSshKeyOpts(await ensureSshKeys()); const proc = Bun.spawn( @@ -1040,7 +1039,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise ...SSH_BASE_OPTS, ...keyOpts, localPath, - `${username}@${_state.serverIp}:${expandedPath}`, + `${username}@${_state.serverIp}:${normalizedRemote}`, ], { stdio: [ @@ -1067,9 +1066,9 @@ export async function downloadFile(remotePath: string, localPath: string): Promi logError(`Invalid local path: ${localPath}`); throw new Error("Invalid local path"); } - const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); + const expandedRemote = remotePath.replace(/^\$HOME\//, "~/"); + const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/); const username = resolveUsername(); - const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const keyOpts = getSshKeyOpts(await ensureSshKeys()); const proc = Bun.spawn( @@ -1077,7 +1076,7 @@ export async function downloadFile(remotePath: string, localPath: string): Promi "scp", ...SSH_BASE_OPTS, ...keyOpts, - `${username}@${_state.serverIp}:${expandedPath}`, + `${username}@${_state.serverIp}:${normalizedRemote}`, localPath, ], { diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index f76c43aed..c38c497ae 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -909,17 +909,17 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str export async function downloadFile(remotePath: string, localPath: string, ip?: string): Promise { const serverIp = ip || _state.serverIp; - const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); + const expandedRemote = remotePath.replace(/^\$HOME\//, "~/"); + const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/); const keyOpts = getSshKeyOpts(await ensureSshKeys()); - const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const proc = Bun.spawn( [ "scp", ...SSH_BASE_OPTS, ...keyOpts, - `root@${serverIp}:${expandedPath}`, + `root@${serverIp}:${normalizedRemote}`, localPath, ], { diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index 047079622..f7284d18f 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -657,10 +657,10 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P /** Download a file from the remote sprite by catting it to stdout. */ export async function downloadFileSprite(remotePath: string, localPath: string): Promise { - const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); + const expandedRemote = remotePath.replace(/^\$HOME\//, "~/"); + const normalizedRemote = validateRemotePath(expandedRemote, /^[a-zA-Z0-9/_.~-]+$/); const spriteCmd = getSpriteCmd()!; - const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); await spriteRetry("sprite download", async () => { const proc = Bun.spawn( @@ -672,7 +672,7 @@ export async function downloadFileSprite(remotePath: string, localPath: string): _state.name, "--", "cat", - expandedPath, + normalizedRemote, ], { stdio: [