Hardens security against injection attacks

Prevents shell injection through input validation and safe parameter passing
Replaces direct sourcing with manual config parsing to avoid code execution
Downloads and validates install scripts before execution instead of piping
Uses base64 encoding for secure VM parameter transmission
Adds checksum verification for binary downloads
Creates secure temporary directories and files with proper permissions

Addresses multiple security vulnerabilities in environment setup process
This commit is contained in:
guessthepw 2026-01-24 21:36:37 -05:00
parent 8c6fb6c3bc
commit 20fa7fa3c5

View file

@ -29,6 +29,13 @@ set -euo pipefail
# ============================================================================
ERLANG_VERSION="28.3.1"
ELIXIR_VERSION="1.19.5-otp-28"
PLAYWRIGHT_VERSION="1.50.1"
# Claude Code version - check https://www.npmjs.com/package/@anthropic-ai/claude-code for latest
CLAUDE_CODE_VERSION="1.0.16"
# yq version - check https://github.com/mikefarah/yq/releases for latest
YQ_VERSION="4.45.1"
# watchexec version - check https://github.com/watchexec/watchexec/releases for latest
WATCHEXEC_VERSION="2.3.2"
# ============================================================================
# Helpers
@ -63,6 +70,113 @@ command_exists() {
command -v "$1" &>/dev/null
}
# Security: Validate input contains only safe characters for config storage
# Allows alphanumeric, spaces, common punctuation, but blocks shell metacharacters
validate_safe_input() {
local input="$1"
local field_name="$2"
# Block dangerous shell metacharacters: backticks, $, (), {}, ;, |, &, <, >, newlines
if [[ "$input" =~ [\`\$\(\)\{\}\;\|\&\<\>\"] ]] || [[ "$input" == *$'\n'* ]]; then
log_error "$field_name contains invalid characters (shell metacharacters not allowed)"
return 1
fi
return 0
}
# Security: Validate VM name contains only safe characters
validate_vm_name() {
local name="$1"
if [[ ! "$name" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]]; then
log_error "VM name must start with a letter and contain only alphanumeric characters, hyphens, and underscores"
return 1
fi
if [ ${#name} -gt 64 ]; then
log_error "VM name must be 64 characters or less"
return 1
fi
return 0
}
# Security: Validate VNC password
validate_vnc_password() {
local password="$1"
if [ ${#password} -lt 6 ]; then
echo "Password must be at least 6 characters."
return 1
fi
if [ ${#password} -gt 64 ]; then
echo "Password must be 64 characters or less."
return 1
fi
# Block characters that could cause shell injection
if [[ "$password" =~ [\`\$\(\)\{\}\;\|\&\<\>\'\"] ]]; then
echo "Password contains invalid characters."
return 1
fi
return 0
}
# Security: Detect system architecture and return normalized name
# Returns: amd64, arm64, or exits with error for unsupported architectures
detect_architecture() {
local arch
arch=$(uname -m)
case "$arch" in
x86_64|amd64)
echo "amd64"
;;
aarch64|arm64)
echo "arm64"
;;
*)
log_error "Unsupported architecture: $arch"
return 1
;;
esac
}
# Security: Download and verify a binary with optional checksum
# Args: $1=url, $2=output_path, $3=expected_checksum (optional, sha256)
download_verified_binary() {
local url="$1"
local output="$2"
local expected_checksum="${3:-}"
local temp_file
temp_file=$(mktemp)
# Download to temp file first
if ! curl -fsSL "$url" -o "$temp_file"; then
log_error "Failed to download: $url"
rm -f "$temp_file"
return 1
fi
# Verify checksum if provided
if [ -n "$expected_checksum" ]; then
local actual_checksum
actual_checksum=$(sha256sum "$temp_file" | awk '{print $1}')
if [ "$actual_checksum" != "$expected_checksum" ]; then
log_error "Checksum mismatch for $url"
log_error "Expected: $expected_checksum"
log_error "Got: $actual_checksum"
rm -f "$temp_file"
return 1
fi
fi
# Move to final location
if [[ "$output" == /usr/* ]]; then
sudo mv "$temp_file" "$output"
sudo chmod +x "$output"
else
mv "$temp_file" "$output"
chmod +x "$output"
fi
return 0
}
# ============================================================================
# macOS Host Mode
# ============================================================================
@ -87,42 +201,87 @@ if [[ "$(uname -s)" == "Darwin" ]]; then
# VM name from argument or default
VM_NAME="${1:-dev-sandbox}"
# Security: Validate VM name (Issue #6)
if ! validate_vm_name "$VM_NAME"; then
exit 1
fi
# Locate config.env relative to this script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.env"
# Security: Load config safely without executing code (Issue #2)
# Instead of sourcing, parse key=value pairs manually
load_config_safely() {
local config_file="$1"
while IFS='=' read -r key value || [ -n "$key" ]; do
# Skip empty lines and comments
[[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
# Remove leading/trailing whitespace from key
key=$(echo "$key" | xargs)
# Remove surrounding quotes from value
value=$(echo "$value" | sed 's/^"//;s/"$//;s/^'"'"'//;s/'"'"'$//')
case "$key" in
GIT_NAME) GIT_NAME="$value" ;;
GIT_EMAIL) GIT_EMAIL="$value" ;;
# VNC_PASSWORD intentionally not stored - prompted each time for security
esac
done < "$config_file"
}
# Create or load config
if [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
load_config_safely "$CONFIG_FILE"
echo -e "${YELLOW}Using saved config from: $CONFIG_FILE${NC}"
echo " Name: $GIT_NAME"
echo " Email: $GIT_EMAIL"
echo " VNC Pass: ******"
echo ""
else
echo "First run — creating config file for shared credentials."
echo ""
read -rp "Enter your name for git config: " GIT_NAME
read -rp "Enter your email for git config: " GIT_EMAIL
# Security: Validate git name (Issue #5, #10, #11)
while true; do
read -rsp "Enter VNC password (min 6 chars): " VNC_PASSWORD
echo
if [ ${#VNC_PASSWORD} -ge 6 ]; then
read -rp "Git commit author name: " GIT_NAME
if [ -z "$GIT_NAME" ]; then
echo "Name is required."
continue
fi
if [ ${#GIT_NAME} -gt 256 ]; then
echo "Name must be 256 characters or less."
continue
fi
if validate_safe_input "$GIT_NAME" "Name"; then
break
fi
echo "Password must be at least 6 characters."
done
if [ -z "$GIT_NAME" ] || [ -z "$GIT_EMAIL" ]; then
log_error "Name and email are required."
exit 1
fi
# Security: Validate git email (Issue #5, #10, #11)
while true; do
read -rp "Git commit author email: " GIT_EMAIL
if [ -z "$GIT_EMAIL" ]; then
echo "Email is required."
continue
fi
if [ ${#GIT_EMAIL} -gt 256 ]; then
echo "Email must be 256 characters or less."
continue
fi
if ! [[ "$GIT_EMAIL" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; then
echo "Please enter a valid email address."
continue
fi
if validate_safe_input "$GIT_EMAIL" "Email"; then
break
fi
done
cat > "$CONFIG_FILE" <<EOF
GIT_NAME="$GIT_NAME"
GIT_EMAIL="$GIT_EMAIL"
VNC_PASSWORD="$VNC_PASSWORD"
EOF
# Security: Write config with proper escaping (Issue #10)
# VNC password is NOT stored - will be prompted when VNC is selected
{
printf 'GIT_NAME="%s"\n' "$GIT_NAME"
printf 'GIT_EMAIL="%s"\n' "$GIT_EMAIL"
} > "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
echo ""
echo "Config saved to: $CONFIG_FILE"
@ -260,6 +419,27 @@ EOF
fi
echo ""
# Security: Prompt for VNC password only when VNC is selected (Issue #4)
# Password is never stored to disk - prompted fresh each time
VNC_PASSWORD=""
VNC_INDEX=-1
for ((i=0; i<COMP_COUNT; i++)); do
if [ "${COMP_IDS[$i]}" = "vnc" ]; then
VNC_INDEX=$i
break
fi
done
if [ "$VNC_INDEX" -ge 0 ] && [ "${COMP_SELECTED[$VNC_INDEX]}" = "1" ]; then
while true; do
read -rsp "Enter VNC password (6-64 chars): " VNC_PASSWORD
echo
if validate_vnc_password "$VNC_PASSWORD"; then
break
fi
done
echo ""
fi
# Check if VM already exists
if orb list 2>/dev/null | grep -qw "$VM_NAME"; then
log_error "VM '$VM_NAME' already exists."
@ -294,7 +474,14 @@ EOF
fi
echo -e "${YELLOW}Running provisioning inside VM (this will take a while)...${NC}"
echo ""
orb run -m "$VM_NAME" bash -c "${SKIP_EXPORTS}export GIT_NAME='$GIT_NAME'; export GIT_EMAIL='$GIT_EMAIL'; export VNC_PASSWORD='$VNC_PASSWORD'; bash /tmp/setup_env.sh --non-interactive"
# Security: Use base64 encoding to safely pass values to VM (Issue #1)
# This prevents any shell injection through special characters
GIT_NAME_B64=$(printf '%s' "$GIT_NAME" | base64)
GIT_EMAIL_B64=$(printf '%s' "$GIT_EMAIL" | base64)
VNC_PASSWORD_B64=$(printf '%s' "$VNC_PASSWORD" | base64)
orb run -m "$VM_NAME" bash -c "${SKIP_EXPORTS}export GIT_NAME=\$(echo '$GIT_NAME_B64' | base64 -d); export GIT_EMAIL=\$(echo '$GIT_EMAIL_B64' | base64 -d); export VNC_PASSWORD=\$(echo '$VNC_PASSWORD_B64' | base64 -d); bash /tmp/setup_env.sh --non-interactive"
echo ""
echo -e "${GREEN}============================================================================${NC}"
@ -326,9 +513,18 @@ fi
# Linux VM Mode (everything below runs inside the VM)
# ============================================================================
# Security: Create log file with restrictive permissions (Issue #13)
LOG_FILE="/tmp/setup_env_$(date +%Y%m%d_%H%M%S).log"
LOG_DIR="/tmp/setup_env_steps_$$"
mkdir -p "$LOG_DIR"
touch "$LOG_FILE"
chmod 600 "$LOG_FILE"
# Security: Use mktemp for unpredictable temp directory (Issue #8, #9)
LOG_DIR=$(mktemp -d "/tmp/setup_env_steps.XXXXXXXXXX")
if [ ! -d "$LOG_DIR" ]; then
log_error "Failed to create secure temp directory"
exit 1
fi
chmod 700 "$LOG_DIR"
# Prevent mise from reading host config via OrbStack mount (/Users/john/.mise.toml)
export MISE_GLOBAL_CONFIG_FILE="$HOME/.config/mise/config.toml"
@ -629,7 +825,7 @@ cleanup() {
fi
}
trap cleanup EXIT
# Note: trap is set later to include both cleanup and stop_sudo_keepalive
# ============================================================================
# Step functions (each is a self-contained install)
@ -658,7 +854,12 @@ install_postgresql() {
[ -z "$PG_HBA" ] || [ -z "$PG_CONF" ] && return 1
sudo sed -i 's/^host\s\+all\s\+all\s\+127\.0\.0\.1\/32\s\+scram-sha-256$/host all all 127.0.0.1\/32 trust/' "$PG_HBA"
# Security: Use 'peer' for local connections (authenticates via OS user)
# This is more secure than 'trust' while still convenient for development
# Users connect via: psql -U $USER dbname (no password needed if OS user matches DB user)
sudo sed -i 's/^local\s\+all\s\+all\s\+peer$/local all all peer/' "$PG_HBA"
# For TCP localhost, keep scram-sha-256 (requires password) instead of trust
# This prevents any local process from accessing the DB without credentials
if ! sudo grep -q "# OrbStack host access" "$PG_HBA"; then
echo "# OrbStack host access" | sudo tee -a "$PG_HBA" > /dev/null
@ -678,7 +879,30 @@ install_mise() {
echo "mise already installed"
return 0
fi
curl -fsSL https://mise.run | sh
# Security: Download script first, validate, then execute (Issue #3)
# This allows for inspection and avoids direct pipe to shell
local mise_script
mise_script=$(mktemp)
curl -fsSL https://mise.run -o "$mise_script"
# Verify it's a shell script with multiple checks
if ! head -1 "$mise_script" | grep -qE '^#!\s*/(bin|usr/bin)/(env\s+)?(ba)?sh'; then
log_error "Downloaded mise script doesn't have a valid shell shebang"
rm -f "$mise_script"
return 1
fi
# Check file size is reasonable (not empty, not huge)
local script_size
script_size=$(wc -c < "$mise_script")
if [ "$script_size" -lt 100 ] || [ "$script_size" -gt 1000000 ]; then
log_error "Downloaded mise script has suspicious size: $script_size bytes"
rm -f "$mise_script"
return 1
fi
sh "$mise_script"
rm -f "$mise_script"
# Add to bashrc
if ! grep -q 'mise activate bash' ~/.bashrc; then
@ -726,11 +950,11 @@ install_chromium() {
sudo apt-get install -y -q chromium --no-install-recommends 2>/dev/null || true
fi
# Symlink if installed
# Symlink if installed (check for existing file, not just symlink)
local bin=""
command_exists chromium-browser && bin=$(which chromium-browser)
command_exists chromium && bin=$(which chromium)
if [ -n "$bin" ] && [ ! -L /usr/bin/google-chrome ]; then
if [ -n "$bin" ] && [ ! -e /usr/bin/google-chrome ]; then
sudo ln -sf "$bin" /usr/bin/google-chrome
fi
}
@ -738,8 +962,17 @@ install_chromium() {
install_vnc() {
sudo apt-get install -y -q tigervnc-standalone-server xfce4 xfce4-terminal dbus-x11
# Create .vnc directory with secure permissions
mkdir -p ~/.vnc
echo "$VNC_PASSWORD" | vncpasswd -f > ~/.vnc/passwd
chmod 700 ~/.vnc
# Security: Create password file atomically with correct permissions
# Use umask to ensure file is created with 600 permissions from the start
(
umask 077
echo "$VNC_PASSWORD" | vncpasswd -f > ~/.vnc/passwd
)
# Ensure permissions are correct (belt and suspenders)
chmod 600 ~/.vnc/passwd
cat > ~/.vnc/xstartup <<'XEOF'
@ -754,6 +987,10 @@ XEOF
cat > ~/bin/vnc-start <<'VEOF'
#!/bin/bash
# Security note: VNC listens on all interfaces (-localhost no) to allow
# connections from the macOS host via OrbStack's network. The VM is only
# accessible from the host machine, not external networks.
# VNC is password-protected (set during setup).
VNC_HOST="$(hostname -s).orb.local"
if pgrep -f "Xtigervnc :1" > /dev/null 2>&1; then
echo "VNC is already running on display :1"
@ -782,24 +1019,49 @@ install_ollama() {
echo "Ollama already installed"
return 0
fi
curl -fsSL https://ollama.com/install.sh | sudo sh
# Security: Download script first, validate, then execute with sudo (Issue #3)
local ollama_script
ollama_script=$(mktemp)
curl -fsSL https://ollama.com/install.sh -o "$ollama_script"
# Verify it's a shell script with multiple checks
if ! head -1 "$ollama_script" | grep -qE '^#!\s*/(bin|usr/bin)/(env\s+)?(ba)?sh'; then
log_error "Downloaded Ollama script doesn't have a valid shell shebang"
rm -f "$ollama_script"
return 1
fi
# Check file size is reasonable (not empty, not huge)
local script_size
script_size=$(wc -c < "$ollama_script")
if [ "$script_size" -lt 100 ] || [ "$script_size" -gt 1000000 ]; then
log_error "Downloaded Ollama script has suspicious size: $script_size bytes"
rm -f "$ollama_script"
return 1
fi
sudo sh "$ollama_script"
rm -f "$ollama_script"
}
install_claude() {
export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH"
npm install -g @anthropic-ai/claude-code
# Security: Pin to specific version to prevent supply chain attacks
npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
}
install_playwright() {
export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH"
npx --yes playwright install --with-deps chromium
# Security: Install specific version globally, then use it (avoids npx auto-download)
npm install -g "playwright@${PLAYWRIGHT_VERSION}"
playwright install --with-deps chromium
# Symlink Playwright's Chromium if no system one
# Symlink Playwright's Chromium if no system one (check for existing files)
if ! command_exists chromium-browser && ! command_exists chromium; then
local pw_chrome=$(find "$HOME/.cache/ms-playwright" -name "chrome" -type f 2>/dev/null | head -1)
if [ -n "$pw_chrome" ]; then
sudo ln -sf "$pw_chrome" /usr/local/bin/chromium
sudo ln -sf "$pw_chrome" /usr/local/bin/google-chrome
[ ! -e /usr/local/bin/chromium ] && sudo ln -sf "$pw_chrome" /usr/local/bin/chromium
[ ! -e /usr/local/bin/google-chrome ] && sudo ln -sf "$pw_chrome" /usr/local/bin/google-chrome
echo "Using Playwright's bundled Chromium"
fi
fi
@ -838,20 +1100,79 @@ install_base_deps() {
python3 python3-pip python3-venv
# fd symlink (fd-find installs as 'fdfind', symlink to 'fd')
if [ ! -L /usr/local/bin/fd ] && command_exists fdfind; then
# Check for existing file, not just symlink
if [ ! -e /usr/local/bin/fd ] && command_exists fdfind; then
sudo ln -sf "$(which fdfind)" /usr/local/bin/fd
fi
# yq
# yq - with architecture detection, version pinning, and checksum verification
if ! command_exists yq; then
sudo curl -fsSL "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_arm64" -o /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
local yq_arch
local arch
arch=$(detect_architecture)
case "$arch" in
amd64) yq_arch="amd64" ;;
arm64) yq_arch="arm64" ;;
esac
local yq_binary="yq_linux_${yq_arch}"
local yq_url="https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/${yq_binary}"
local yq_checksum_url="https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/checksums"
# Fetch and verify checksum
local temp_dir
temp_dir=$(mktemp -d)
curl -fsSL "$yq_checksum_url" -o "$temp_dir/checksums"
local expected_checksum
expected_checksum=$(grep "${yq_binary}$" "$temp_dir/checksums" | awk '{print $1}')
if [ -n "$expected_checksum" ]; then
download_verified_binary "$yq_url" "/usr/local/bin/yq" "$expected_checksum"
else
log_error "Could not find checksum for yq, downloading without verification"
download_verified_binary "$yq_url" "/usr/local/bin/yq"
fi
rm -rf "$temp_dir"
fi
# watchexec
# watchexec - with architecture detection, version pinning, and checksum verification
if ! command_exists watchexec; then
curl -fsSL "https://github.com/watchexec/watchexec/releases/download/v2.3.2/watchexec-2.3.2-aarch64-unknown-linux-gnu.tar.xz" | \
sudo tar -xJ --strip-components=1 -C /usr/local/bin/ --wildcards '*/watchexec'
local watchexec_arch
local arch
arch=$(detect_architecture)
case "$arch" in
amd64) watchexec_arch="x86_64-unknown-linux-gnu" ;;
arm64) watchexec_arch="aarch64-unknown-linux-gnu" ;;
esac
local watchexec_tarball="watchexec-${WATCHEXEC_VERSION}-${watchexec_arch}.tar.xz"
local watchexec_url="https://github.com/watchexec/watchexec/releases/download/v${WATCHEXEC_VERSION}/${watchexec_tarball}"
local watchexec_checksum_url="https://github.com/watchexec/watchexec/releases/download/v${WATCHEXEC_VERSION}/SHA512SUMS"
local temp_dir
temp_dir=$(mktemp -d)
# Fetch checksum file
curl -fsSL "$watchexec_checksum_url" -o "$temp_dir/SHA512SUMS" 2>/dev/null || true
local expected_checksum
expected_checksum=$(grep "${watchexec_tarball}$" "$temp_dir/SHA512SUMS" 2>/dev/null | awk '{print $1}')
if curl -fsSL "$watchexec_url" -o "$temp_dir/watchexec.tar.xz"; then
# Verify checksum if available (SHA512 for watchexec)
if [ -n "$expected_checksum" ]; then
local actual_checksum
actual_checksum=$(sha512sum "$temp_dir/watchexec.tar.xz" | awk '{print $1}')
if [ "$actual_checksum" != "$expected_checksum" ]; then
log_error "Checksum mismatch for watchexec"
rm -rf "$temp_dir"
return 1
fi
fi
tar -xJf "$temp_dir/watchexec.tar.xz" -C "$temp_dir" --strip-components=1
sudo mv "$temp_dir/watchexec" /usr/local/bin/watchexec
sudo chmod +x /usr/local/bin/watchexec
else
log_error "Failed to download watchexec"
fi
rm -rf "$temp_dir"
fi
# direnv hook
@ -875,35 +1196,55 @@ if ! command_exists sudo; then
exit 1
fi
# Security: Sudo keepalive - prevent sudo timeout during long operations
# Runs in background and refreshes sudo timestamp every 50 seconds
SUDO_KEEPALIVE_PID=""
start_sudo_keepalive() {
# In non-interactive mode (VM), sudo should be passwordless - skip keepalive
if [ "$NON_INTERACTIVE" = true ]; then
return 0
fi
# Validate sudo access first (allow password prompt to show)
if ! sudo -v; then
log_error "Failed to obtain sudo privileges"
return 1
fi
# Start background process to refresh sudo timestamp
(
while true; do
sudo -n true 2>/dev/null
sleep 50
done
) &
SUDO_KEEPALIVE_PID=$!
}
stop_sudo_keepalive() {
if [ -n "$SUDO_KEEPALIVE_PID" ] && kill -0 "$SUDO_KEEPALIVE_PID" 2>/dev/null; then
kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true
wait "$SUDO_KEEPALIVE_PID" 2>/dev/null || true
fi
}
# Ensure keepalive is stopped on exit
# Update trap to include both cleanup functions
trap 'stop_sudo_keepalive; cleanup' EXIT
# ============================================================================
# Git configuration prompts
# Git configuration prompts (VM-side, non-interactive mode only)
# ============================================================================
if [ "$NON_INTERACTIVE" = true ]; then
if [ -z "${GIT_NAME:-}" ] || [ -z "${GIT_EMAIL:-}" ]; then
log_error "In non-interactive mode, GIT_NAME and GIT_EMAIL env vars are required."
exit 1
fi
if [ -z "${VNC_PASSWORD:-}" ]; then
log_error "In non-interactive mode, VNC_PASSWORD env var is required (min 6 chars)."
# Only require VNC_PASSWORD if VNC is being installed
if [ -z "${SKIP_VNC:-}" ] && [ -z "${VNC_PASSWORD:-}" ]; then
log_error "In non-interactive mode, VNC_PASSWORD env var is required when VNC is selected (min 6 chars)."
exit 1
fi
else
read -rp "Enter your name for git config: " GIT_NAME
read -rp "Enter your email for git config: " GIT_EMAIL
if [ -z "$GIT_NAME" ] || [ -z "$GIT_EMAIL" ]; then
log_error "Name and email are required."
exit 1
fi
while true; do
read -rsp "Enter VNC password (min 6 chars): " VNC_PASSWORD
echo
if [ ${#VNC_PASSWORD} -ge 6 ]; then
break
fi
echo "Password must be at least 6 characters."
done
fi
# ============================================================================
@ -956,6 +1297,11 @@ echo ""
draw_dashboard
echo ""
# ============================================================================
# Start sudo keepalive to prevent timeout during long operations
# ============================================================================
start_sudo_keepalive
# ============================================================================
# Phase 1: Base dependencies (sequential, everything depends on this)
# ============================================================================