fix(security): prevent path traversal in uploadFile/downloadFile across all cloud providers (#2844)

Check for ".." path traversal in the raw input BEFORE normalize() strips
it, fixing CWE-22 where crafted paths like "/tmp/../../etc/passwd"
normalized to "/etc/passwd" and bypassed the post-normalize ".." check.

Extracts a shared validateRemotePath() into shared/ssh.ts and replaces
the duplicated inline validation in all 5 providers (DigitalOcean,
Hetzner, GCP, AWS, Sprite) plus agent-setup.ts.

Fixes #2835

Agent: complexity-hunter

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-20 16:48:58 -07:00 committed by GitHub
parent b9e326d649
commit ffb4cbeb11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 65 additions and 115 deletions

View file

@ -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<void
}
export async function uploadFile(localPath: string, remotePath: string): Promise<void> {
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<void> {
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(

View file

@ -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<void> {
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<void> {
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/, "~");

View file

@ -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());

View file

@ -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<void> {
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<void> {
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/, "~");

View file

@ -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.
*/

View file

@ -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 ───────────────────────────────────────────────────────
/**

View file

@ -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<void> {
* The -file flag format is "localpath:remotepath".
*/
export async function uploadFileSprite(localPath: string, remotePath: string): Promise<void> {
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<void> {
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/, "~");