From 52fa8e12a148d8603e320e12a0a4ebe53c130bcf Mon Sep 17 00:00:00 2001 From: B <6723574+louisgv@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:41:20 +0000 Subject: [PATCH 1/3] fix(security): propagate path normalization to all cloud upload/download functions PR #2690 added normalize() before path traversal checks in AWS but not the other clouds. Apply the same defense-in-depth to GCP, DigitalOcean, Hetzner, Sprite, and shared validateRemotePath. Agent: code-health Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/digitalocean/digitalocean.ts | 15 +++++++++------ packages/cli/src/gcp/gcp.ts | 16 +++++++++------- packages/cli/src/hetzner/hetzner.ts | 15 +++++++++------ packages/cli/src/shared/agent-setup.ts | 9 +++++---- packages/cli/src/sprite/sprite.ts | 16 +++++++++------- 5 files changed, 41 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index ae331e8d..ff4d64c1 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -4,6 +4,7 @@ import type { CloudInstance, VMConnection } from "../history.js"; import type { CloudInitTier } from "../shared/agents"; import { mkdirSync, readFileSync } from "node:fs"; +import { normalize } from "node:path"; import * as p from "@clack/prompts"; import { getErrorMessage, isNumber, isString, toObjectArray, toRecord } from "@openrouter/spawn-shared"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance"; @@ -1307,10 +1308,11 @@ export async function runServer(cmd: string, timeoutSecs?: number, ip?: string): export async function uploadFile(localPath: string, remotePath: string, ip?: string): Promise { const serverIp = ip || _state.serverIp; + const normalizedRemote = normalize(remotePath); if ( - !/^[a-zA-Z0-9/_.~-]+$/.test(remotePath) || - remotePath.includes("..") || - remotePath.split("/").some((s) => s.startsWith("-")) + !/^[a-zA-Z0-9/_.~-]+$/.test(normalizedRemote) || + normalizedRemote.includes("..") || + normalizedRemote.split("/").some((s) => s.startsWith("-")) ) { logError(`Invalid remote path: ${remotePath}`); throw new Error("Invalid remote path"); @@ -1346,10 +1348,11 @@ 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 = normalize(remotePath); if ( - !/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) || - remotePath.includes("..") || - remotePath.split("/").some((s) => s.startsWith("-")) + !/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) || + normalizedRemote.includes("..") || + normalizedRemote.split("/").some((s) => s.startsWith("-")) ) { logError(`Invalid remote path: ${remotePath}`); throw new Error("Invalid remote path"); diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index d8ff6f75..4c868dec 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -4,7 +4,7 @@ import type { CloudInstance, VMConnection } from "../history.js"; import type { CloudInitTier } from "../shared/agents"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, normalize } from "node:path"; import { isString, toObjectArray } from "@openrouter/spawn-shared"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance"; import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init"; @@ -997,10 +997,11 @@ export async function uploadFile(localPath: string, remotePath: string): Promise logError(`Invalid local path: ${localPath}`); throw new Error("Invalid local path"); } + const normalizedRemote = normalize(remotePath); if ( - !/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) || - remotePath.includes("..") || - remotePath.split("/").some((s) => s.startsWith("-")) + !/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) || + normalizedRemote.includes("..") || + normalizedRemote.split("/").some((s) => s.startsWith("-")) ) { logError(`Invalid remote path: ${remotePath}`); throw new Error("Invalid remote path"); @@ -1043,10 +1044,11 @@ export async function downloadFile(remotePath: string, localPath: string): Promi logError(`Invalid local path: ${localPath}`); throw new Error("Invalid local path"); } + const normalizedRemote = normalize(remotePath); if ( - !/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) || - remotePath.includes("..") || - remotePath.split("/").some((s) => s.startsWith("-")) + !/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) || + normalizedRemote.includes("..") || + normalizedRemote.split("/").some((s) => s.startsWith("-")) ) { logError(`Invalid remote path: ${remotePath}`); throw new Error("Invalid remote path"); diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index 741472e4..de60ec63 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -4,6 +4,7 @@ import type { CloudInstance, VMConnection } from "../history.js"; import type { CloudInitTier } from "../shared/agents"; import { mkdirSync, readFileSync } from "node:fs"; +import { normalize } from "node:path"; import { getErrorMessage, isNumber, isString, toObjectArray, toRecord } from "@openrouter/spawn-shared"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance"; import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init"; @@ -615,10 +616,11 @@ export async function runServer(cmd: string, timeoutSecs?: number, ip?: string): export async function uploadFile(localPath: string, remotePath: string, ip?: string): Promise { const serverIp = ip || _state.serverIp; + const normalizedRemote = normalize(remotePath); if ( - !/^[a-zA-Z0-9/_.~-]+$/.test(remotePath) || - remotePath.includes("..") || - remotePath.split("/").some((s) => s.startsWith("-")) + !/^[a-zA-Z0-9/_.~-]+$/.test(normalizedRemote) || + normalizedRemote.includes("..") || + normalizedRemote.split("/").some((s) => s.startsWith("-")) ) { logError(`Invalid remote path: ${remotePath}`); throw new Error("Invalid remote path"); @@ -655,10 +657,11 @@ 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 = normalize(remotePath); if ( - !/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) || - remotePath.includes("..") || - remotePath.split("/").some((s) => s.startsWith("-")) + !/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) || + normalizedRemote.includes("..") || + normalizedRemote.split("/").some((s) => s.startsWith("-")) ) { logError(`Invalid remote path: ${remotePath}`); throw new Error("Invalid remote path"); diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 03e3f517..d40953bd 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -5,7 +5,7 @@ import type { AgentConfig } from "./agents"; import type { Result } from "./ui"; import { unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, normalize } from "node:path"; import { getErrorMessage } from "@openrouter/spawn-shared"; import { getTmpDir } from "./paths"; import { asyncTryCatch, asyncTryCatchIf, isOperationalError, tryCatchIf } from "./result.js"; @@ -68,11 +68,12 @@ function validateRemotePath(remotePath: string): void { // Allow alphanumerics, forward slashes, dots, underscores, tildes, hyphens, // and shell variable syntax ($, {, }). Reject everything else — especially // backticks, semicolons, pipes, quotes, newlines, and null bytes. - if (!/^[\w/.~${}:-]+$/.test(remotePath)) { + const normalizedRemote = normalize(remotePath); + if (!/^[\w/.~${}:-]+$/.test(normalizedRemote)) { throw new Error(`uploadConfigFile: remotePath contains unsafe characters: ${remotePath}`); } - // Block path traversal - if (remotePath.includes("..")) { + // Block path traversal (normalize resolves . segments first) + if (normalizedRemote.includes("..")) { throw new Error(`uploadConfigFile: remotePath must not contain "..": ${remotePath}`); } } diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index e518b351..712e3a28 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -3,7 +3,7 @@ import type { VMConnection } from "../history.js"; import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, normalize } from "node:path"; import { getErrorMessage } from "@openrouter/spawn-shared"; import { getUserHome } from "../shared/paths"; import { asyncTryCatch } from "../shared/result.js"; @@ -506,10 +506,11 @@ async function runSpriteSilent(cmd: string): Promise { * The -file flag format is "localpath:remotepath". */ export async function uploadFileSprite(localPath: string, remotePath: string): Promise { + const normalizedRemote = normalize(remotePath); if ( - !/^[a-zA-Z0-9/_.~-]+$/.test(remotePath) || - remotePath.includes("..") || - remotePath.split("/").some((s) => s.startsWith("-")) + !/^[a-zA-Z0-9/_.~-]+$/.test(normalizedRemote) || + normalizedRemote.includes("..") || + normalizedRemote.split("/").some((s) => s.startsWith("-")) ) { logError(`Invalid remote path: ${remotePath}`); throw new Error("Invalid remote path"); @@ -555,10 +556,11 @@ 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 = normalize(remotePath); if ( - !/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) || - remotePath.includes("..") || - remotePath.split("/").some((s) => s.startsWith("-")) + !/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) || + normalizedRemote.includes("..") || + normalizedRemote.split("/").some((s) => s.startsWith("-")) ) { logError(`Invalid remote path: ${remotePath}`); throw new Error("Invalid remote path"); From 244aa5e12c006d5e979eb6681487d4feae33e708 Mon Sep 17 00:00:00 2001 From: B <6723574+louisgv@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:54:52 +0000 Subject: [PATCH 2/3] fix(security): use normalized path in all file transfer operations Addresses code review: replace original remotePath with normalizedRemote in scp commands and bash operations to prevent validation bypass. - digitalocean: use normalizedRemote in uploadFile scp and derive expandedPath from normalizedRemote in downloadFile - hetzner: same pattern for uploadFile/downloadFile - gcp: derive expandedPath from normalizedRemote.replace(...) in both uploadFile and downloadFile - sprite: use normalizedRemote in bash mkdir/mv command and derive expandedPath from normalizedRemote in downloadFile Agent: pr-maintainer Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/package.json | 2 +- packages/cli/src/digitalocean/digitalocean.ts | 4 ++-- packages/cli/src/gcp/gcp.ts | 4 ++-- packages/cli/src/hetzner/hetzner.ts | 4 ++-- packages/cli/src/sprite/sprite.ts | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index e97668fe..2bfb1dab 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.20.4", + "version": "0.20.5", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index ff4d64c1..c02aa910 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -1325,7 +1325,7 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str ...SSH_BASE_OPTS, ...keyOpts, localPath, - `root@${serverIp}:${remotePath}`, + `root@${serverIp}:${normalizedRemote}`, ], { stdio: [ @@ -1359,7 +1359,7 @@ export async function downloadFile(remotePath: string, localPath: string, ip?: s } const keyOpts = getSshKeyOpts(await ensureSshKeys()); - const expandedPath = remotePath.replace(/^\$HOME/, "~"); + const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const proc = Bun.spawn( [ diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index 4c868dec..5f81dbe3 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -1008,7 +1008,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise } const username = resolveUsername(); // Expand $HOME on remote side - const expandedPath = remotePath.replace(/^\$HOME/, "~"); + const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const keyOpts = getSshKeyOpts(await ensureSshKeys()); const proc = Bun.spawn( @@ -1054,7 +1054,7 @@ export async function downloadFile(remotePath: string, localPath: string): Promi throw new Error("Invalid remote path"); } const username = resolveUsername(); - const expandedPath = remotePath.replace(/^\$HOME/, "~"); + const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const keyOpts = getSshKeyOpts(await ensureSshKeys()); const proc = Bun.spawn( diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index de60ec63..e33d0331 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -634,7 +634,7 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str ...SSH_BASE_OPTS, ...keyOpts, localPath, - `root@${serverIp}:${remotePath}`, + `root@${serverIp}:${normalizedRemote}`, ], { stdio: [ @@ -668,7 +668,7 @@ export async function downloadFile(remotePath: string, localPath: string, ip?: s } const keyOpts = getSshKeyOpts(await ensureSshKeys()); - const expandedPath = remotePath.replace(/^\$HOME/, "~"); + const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const proc = Bun.spawn( [ diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index 712e3a28..810bd7f9 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -519,7 +519,7 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P const spriteCmd = getSpriteCmd()!; // Generate a random temp path on remote to prevent symlink attacks const tempRandom = crypto.randomUUID().replace(/-/g, "").slice(0, 16); - const basename = remotePath.split("/").pop() || "file"; + const basename = normalizedRemote.split("/").pop() || "file"; const tempRemote = `/tmp/sprite_upload_${basename}_${tempRandom}`; await spriteRetry("sprite upload", async () => { @@ -535,7 +535,7 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P "--", "bash", "-c", - `mkdir -p $(dirname '${remotePath}') && mv '${tempRemote}' '${remotePath}'`, + `mkdir -p $(dirname '${normalizedRemote}') && mv '${tempRemote}' '${normalizedRemote}'`, ], { stdio: [ @@ -567,7 +567,7 @@ export async function downloadFileSprite(remotePath: string, localPath: string): } const spriteCmd = getSpriteCmd()!; - const expandedPath = remotePath.replace(/^\$HOME/, "~"); + const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); await spriteRetry("sprite download", async () => { const proc = Bun.spawn( From 9060d94ec16415ff202f89b7b9b1da1b84699642 Mon Sep 17 00:00:00 2001 From: B <6723574+louisgv@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:19:54 +0000 Subject: [PATCH 3/3] fix(security): close validation bypass in agent-setup and AWS file ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateRemotePath() validated the normalized path but returned void, so the caller still used the original unsanitized remotePath in shell commands — bypassing the normalization check entirely. Fix: return the normalized path and use it in all file operations. Also fix AWS uploadFile/downloadFile which validated normalizedRemote but used the original remotePath in scp commands. Agent: pr-maintainer Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/aws/aws.ts | 4 ++-- packages/cli/src/shared/agent-setup.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index a079d477..b54b86e9 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -1145,7 +1145,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise ...SSH_BASE_OPTS, ...keyOpts, localPath, - `${SSH_USER}@${_state.instanceIp}:${remotePath}`, + `${SSH_USER}@${_state.instanceIp}:${normalizedRemote}`, ], { stdio: [ @@ -1176,7 +1176,7 @@ export async function downloadFile(remotePath: string, localPath: string): Promi throw new Error(`Invalid remote path: ${remotePath}`); } const keyOpts = getSshKeyOpts(await ensureSshKeys()); - const expandedPath = remotePath.replace(/^\$HOME/, "~"); + const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const proc = Bun.spawn( [ "scp", diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index d40953bd..1fc6dfd0 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -64,7 +64,7 @@ async function installAgent( * Allows shell variable references ($HOME, ${HOME}) but rejects anything * that could break out of double-quoted shell interpolation. */ -function validateRemotePath(remotePath: string): void { +function validateRemotePath(remotePath: string): string { // Allow alphanumerics, forward slashes, dots, underscores, tildes, hyphens, // and shell variable syntax ($, {, }). Reject everything else — especially // backticks, semicolons, pipes, quotes, newlines, and null bytes. @@ -76,13 +76,14 @@ function validateRemotePath(remotePath: string): void { if (normalizedRemote.includes("..")) { throw new Error(`uploadConfigFile: remotePath must not contain "..": ${remotePath}`); } + return normalizedRemote; } /** * Upload a config file to the remote machine via a temp file and mv. */ async function uploadConfigFile(runner: CloudRunner, content: string, remotePath: string): Promise { - validateRemotePath(remotePath); + const safePath = validateRemotePath(remotePath); const tmpFile = join(getTmpDir(), `spawn_config_${Date.now()}_${Math.random().toString(36).slice(2)}`); writeFileSync(tmpFile, content, { @@ -98,7 +99,7 @@ async function uploadConfigFile(runner: CloudRunner, content: string, remotePath const tempRemote = `/tmp/spawn_config_${Date.now()}`; await runner.uploadFile(tmpFile, tempRemote); await runner.runServer( - `mkdir -p $(dirname "${remotePath}") && chmod 600 ${shellQuote(tempRemote)} && mv ${shellQuote(tempRemote)} "${remotePath}"`, + `mkdir -p $(dirname "${safePath}") && chmod 600 ${shellQuote(tempRemote)} && mv ${shellQuote(tempRemote)} "${safePath}"`, ); })(), ),