diff --git a/packages/cli/src/aws/aws.ts b/packages/cli/src/aws/aws.ts index ca71b17d..6664da6f 100644 --- a/packages/cli/src/aws/aws.ts +++ b/packages/cli/src/aws/aws.ts @@ -5,7 +5,7 @@ import type { CloudInitTier } from "../shared/agents.js"; import { createHash, createHmac } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, normalize } from "node:path"; +import { dirname } from "node:path"; import { getErrorMessage } from "@openrouter/spawn-shared"; import * as v from "valibot"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance.js"; @@ -20,6 +20,7 @@ import { waitForSsh as sharedWaitForSsh, sleep, spawnInteractive, + validateRemotePath, } from "../shared/ssh.js"; import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys.js"; import { @@ -1147,14 +1148,7 @@ export async function runServer(cmd: string, timeoutSecs?: number): Promise { - const normalizedRemote = normalize(remotePath); - if ( - !/^[a-zA-Z0-9/_.~-]+$/.test(normalizedRemote) || - normalizedRemote.includes("..") || - normalizedRemote.split("/").some((s) => s.startsWith("-")) - ) { - throw new Error(`Invalid remote path: ${remotePath}`); - } + const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~-]+$/); const keyOpts = getSshKeyOpts(await ensureSshKeys()); const proc = Bun.spawn( [ @@ -1184,14 +1178,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise } export async function downloadFile(remotePath: string, localPath: string): Promise { - const normalizedRemote = normalize(remotePath); - if ( - !/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) || - normalizedRemote.includes("..") || - normalizedRemote.split("/").some((s) => s.startsWith("-")) - ) { - throw new Error(`Invalid remote path: ${remotePath}`); - } + const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); const keyOpts = getSshKeyOpts(await ensureSshKeys()); const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const proc = Bun.spawn( diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 397f578e..2ebaf290 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -4,7 +4,7 @@ import type { CloudInstance, VMConnection } from "../history.js"; import type { CloudInitTier } from "../shared/agents.js"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, normalize } from "node:path"; +import { dirname } 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.js"; @@ -28,6 +28,7 @@ import { waitForSsh as sharedWaitForSsh, sleep, spawnInteractive, + validateRemotePath, waitForSshSnapshotBoot, } from "../shared/ssh.js"; import { ensureSshKeys, getSshFingerprint, getSshKeyOpts } from "../shared/ssh-keys.js"; @@ -1367,15 +1368,7 @@ 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(normalizedRemote) || - normalizedRemote.includes("..") || - normalizedRemote.split("/").some((s) => s.startsWith("-")) - ) { - logError(`Invalid remote path: ${remotePath}`); - throw new Error("Invalid remote path"); - } + const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~-]+$/); const keyOpts = getSshKeyOpts(await ensureSshKeys()); const proc = Bun.spawn( @@ -1407,15 +1400,7 @@ 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(normalizedRemote) || - normalizedRemote.includes("..") || - normalizedRemote.split("/").some((s) => s.startsWith("-")) - ) { - logError(`Invalid remote path: ${remotePath}`); - throw new Error("Invalid remote path"); - } + const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); const keyOpts = getSshKeyOpts(await ensureSshKeys()); const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index ca78a156..2ed11f49 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.js"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join, normalize } from "node:path"; +import { join } from "node:path"; import { isString, toObjectArray } from "@openrouter/spawn-shared"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance.js"; import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init.js"; @@ -17,6 +17,7 @@ import { waitForSsh as sharedWaitForSsh, sleep, spawnInteractive, + validateRemotePath, } from "../shared/ssh.js"; import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys.js"; import { @@ -1007,15 +1008,7 @@ 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(normalizedRemote) || - normalizedRemote.includes("..") || - normalizedRemote.split("/").some((s) => s.startsWith("-")) - ) { - logError(`Invalid remote path: ${remotePath}`); - throw new Error("Invalid remote path"); - } + const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); const username = resolveUsername(); // Expand $HOME on remote side const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); @@ -1054,15 +1047,7 @@ 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(normalizedRemote) || - normalizedRemote.includes("..") || - normalizedRemote.split("/").some((s) => s.startsWith("-")) - ) { - logError(`Invalid remote path: ${remotePath}`); - throw new Error("Invalid remote path"); - } + const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); const username = resolveUsername(); const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); const keyOpts = getSshKeyOpts(await ensureSshKeys()); diff --git a/packages/cli/src/hetzner/hetzner.ts b/packages/cli/src/hetzner/hetzner.ts index 734b0609..3e00d8af 100644 --- a/packages/cli/src/hetzner/hetzner.ts +++ b/packages/cli/src/hetzner/hetzner.ts @@ -4,7 +4,7 @@ import type { CloudInstance, VMConnection } from "../history.js"; import type { CloudInitTier } from "../shared/agents.js"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, normalize } from "node:path"; +import { dirname } from "node:path"; import { getErrorMessage, isNumber, isString, toObjectArray, toRecord } from "@openrouter/spawn-shared"; import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance.js"; import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init.js"; @@ -18,6 +18,7 @@ import { waitForSsh as sharedWaitForSsh, sleep, spawnInteractive, + validateRemotePath, waitForSshSnapshotBoot, } from "../shared/ssh.js"; import { ensureSshKeys, getSshFingerprint, getSshKeyOpts } from "../shared/ssh-keys.js"; @@ -777,15 +778,7 @@ 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(normalizedRemote) || - normalizedRemote.includes("..") || - normalizedRemote.split("/").some((s) => s.startsWith("-")) - ) { - logError(`Invalid remote path: ${remotePath}`); - throw new Error("Invalid remote path"); - } + const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~-]+$/); const keyOpts = getSshKeyOpts(await ensureSshKeys()); @@ -818,15 +811,7 @@ 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(normalizedRemote) || - normalizedRemote.includes("..") || - normalizedRemote.split("/").some((s) => s.startsWith("-")) - ) { - logError(`Invalid remote path: ${remotePath}`); - throw new Error("Invalid remote path"); - } + const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); const keyOpts = getSshKeyOpts(await ensureSshKeys()); const expandedPath = normalizedRemote.replace(/^\$HOME/, "~"); diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index d68e57e6..536255a7 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -5,10 +5,11 @@ import type { AgentConfig } from "./agents.js"; import type { Result } from "./ui.js"; import { unlinkSync, writeFileSync } from "node:fs"; -import { join, normalize } from "node:path"; +import { join } from "node:path"; import { getErrorMessage } from "@openrouter/spawn-shared"; import { getTmpDir } from "./paths.js"; import { asyncTryCatch, asyncTryCatchIf, isOperationalError, tryCatchIf } from "./result.js"; +import { validateRemotePath } from "./ssh.js"; import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, prompt, shellQuote, withRetry } from "./ui.js"; /** @@ -59,26 +60,6 @@ async function installAgent( logInfo(`${agentName} installation completed`); } -/** - * Validate that a remote path contains only safe characters. - * Allows shell variable references ($HOME, ${HOME}) but rejects anything - * that could break out of double-quoted shell interpolation. - */ -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. - const normalizedRemote = normalize(remotePath); - if (!/^[\w/.~${}:-]+$/.test(normalizedRemote)) { - throw new Error(`uploadConfigFile: remotePath contains unsafe characters: ${remotePath}`); - } - // 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. */ diff --git a/packages/cli/src/shared/ssh.ts b/packages/cli/src/shared/ssh.ts index 439d2626..2f2cf321 100644 --- a/packages/cli/src/shared/ssh.ts +++ b/packages/cli/src/shared/ssh.ts @@ -2,6 +2,7 @@ import { spawnSync as nodeSpawnSync } from "node:child_process"; import { connect } from "node:net"; +import { normalize } from "node:path"; import { asyncTryCatch, tryCatch } from "./result.js"; import { logError, logInfo, logStep, logStepDone, logStepInline } from "./ui.js"; @@ -69,6 +70,48 @@ export const SSH_INTERACTIVE_OPTS: string[] = [ "-t", ]; +// ─── Remote Path Validation ───────────────────────────────────────────────── + +/** + * Validate a remote file path for use with scp/ssh file operations. + * + * Rejects path traversal (.. segments), argument injection (leading dashes), + * and characters outside a safe allowlist. The `..` check is performed on the + * RAW input before normalize() so that crafted paths like `/tmp/../../etc/passwd` + * (which normalize to `/etc/passwd`) are still caught. + * + * @param remotePath - The raw remote path to validate + * @param allowedCharsPattern - Optional regex for allowed characters + * (default: alphanumerics, `/`, `.`, `_`, `~`, `$`, `{`, `}`, `:`, `-`) + * @returns The normalized path if valid + * @throws Error if the path is unsafe + */ +export function validateRemotePath(remotePath: string, allowedCharsPattern: RegExp = /^[\w/.~${}:-]+$/): string { + // 1. Check for ".." traversal in the RAW input BEFORE normalize() strips it + if (remotePath.includes("..")) { + throw new Error(`Invalid remote path: path traversal detected ("..") in: ${remotePath}`); + } + // 2. Reject empty paths + if (!remotePath) { + throw new Error("Invalid remote path: path must not be empty"); + } + // 3. Normalize (resolve . segments, collapse slashes) + const normalized = normalize(remotePath); + // 4. Double-check normalized result for ".." (defense in depth) + if (normalized.includes("..")) { + throw new Error(`Invalid remote path: path traversal detected ("..") in normalized: ${normalized}`); + } + // 5. Character allowlist + if (!allowedCharsPattern.test(normalized)) { + throw new Error(`Invalid remote path: contains unsafe characters: ${remotePath}`); + } + // 6. Reject argument injection (segments starting with -) + if (normalized.split("/").some((s) => s.startsWith("-"))) { + throw new Error(`Invalid remote path: segments must not start with "-": ${remotePath}`); + } + return normalized; +} + // ─── Interactive Spawn ─────────────────────────────────────────────────────── /** diff --git a/packages/cli/src/sprite/sprite.ts b/packages/cli/src/sprite/sprite.ts index f1cb979f..ef1cd00e 100644 --- a/packages/cli/src/sprite/sprite.ts +++ b/packages/cli/src/sprite/sprite.ts @@ -3,11 +3,11 @@ import type { VMConnection } from "../history.js"; import { existsSync } from "node:fs"; -import { join, normalize } from "node:path"; +import { join } from "node:path"; import { getErrorMessage } from "@openrouter/spawn-shared"; import { getUserHome } from "../shared/paths.js"; import { asyncTryCatch } from "../shared/result.js"; -import { killWithTimeout, sleep, spawnInteractive } from "../shared/ssh.js"; +import { killWithTimeout, sleep, spawnInteractive, validateRemotePath } from "../shared/ssh.js"; import { getServerNameFromEnv, logError, @@ -506,15 +506,7 @@ 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(normalizedRemote) || - normalizedRemote.includes("..") || - normalizedRemote.split("/").some((s) => s.startsWith("-")) - ) { - logError(`Invalid remote path: ${remotePath}`); - throw new Error("Invalid remote path"); - } + const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~-]+$/); const spriteCmd = getSpriteCmd()!; // Generate a random temp path on remote to prevent symlink attacks @@ -556,15 +548,7 @@ 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(normalizedRemote) || - normalizedRemote.includes("..") || - normalizedRemote.split("/").some((s) => s.startsWith("-")) - ) { - logError(`Invalid remote path: ${remotePath}`); - throw new Error("Invalid remote path"); - } + const normalizedRemote = validateRemotePath(remotePath, /^[a-zA-Z0-9/_.~$-]+$/); const spriteCmd = getSpriteCmd()!; const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");