mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-20 09:31:15 +00:00
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:
parent
b9e326d649
commit
ffb4cbeb11
7 changed files with 65 additions and 115 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/, "~");
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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/, "~");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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/, "~");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue