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/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/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index ae331e8d..c02aa910 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"); @@ -1323,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: [ @@ -1346,17 +1348,18 @@ 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"); } 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 d8ff6f75..5f81dbe3 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,17 +997,18 @@ 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"); } 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( @@ -1043,16 +1044,17 @@ 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"); } 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 741472e4..e33d0331 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"); @@ -632,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: [ @@ -655,17 +657,18 @@ 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"); } 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/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 03e3f517..1fc6dfd0 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"; @@ -64,24 +64,26 @@ 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. - 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}`); } + 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, { @@ -97,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}"`, ); })(), ), diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index e518b351..810bd7f9 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"); @@ -518,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 () => { @@ -534,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: [ @@ -555,17 +556,18 @@ 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"); } const spriteCmd = getSpriteCmd()!; - const expandedPath = remotePath.replace(/^\$HOME/, "~"); + const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); await spriteRetry("sprite download", async () => { const proc = Bun.spawn(