From e9f8d5ec2d264405f0a1475e018af0d79fa7a94e Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:54:32 -0700 Subject: [PATCH] fix: secure curl header args and provision.sh export whitelist (fixes #2464, fixes #2465) (#2471) - Replace `-H "Authorization: Bearer ..."` curl args with temp curl config files (`-K`) in digitalocean.sh and hetzner.sh e2e drivers, keeping API tokens out of `ps` output - Replace dangerous-var blocklist in provision.sh with a positive whitelist of allowed cloud_headless_env variable names Agent: complexity-hunter Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- sh/e2e/lib/clouds/digitalocean.sh | 36 ++++++++++++++++++++----------- sh/e2e/lib/clouds/hetzner.sh | 33 +++++++++++++++++++--------- sh/e2e/lib/provision.sh | 19 ++++++++-------- 3 files changed, 56 insertions(+), 32 deletions(-) diff --git a/sh/e2e/lib/clouds/digitalocean.sh b/sh/e2e/lib/clouds/digitalocean.sh index 9131bfec..2f18e811 100644 --- a/sh/e2e/lib/clouds/digitalocean.sh +++ b/sh/e2e/lib/clouds/digitalocean.sh @@ -16,6 +16,24 @@ _DO_API="https://api.digitalocean.com/v2" _DO_DEFAULT_SIZE="s-2vcpu-2gb" _DO_DEFAULT_REGION="nyc3" +# --------------------------------------------------------------------------- +# _do_curl_auth [curl-args...] +# +# Wrapper around curl that passes the DO_API_TOKEN via a temp config file +# instead of a command-line -H flag. This keeps the token out of `ps` output. +# All arguments are forwarded to curl. +# --------------------------------------------------------------------------- +_do_curl_auth() { + local _cfg + _cfg=$(mktemp) + chmod 600 "${_cfg}" + printf 'header = "Authorization: Bearer %s"\n' "${DO_API_TOKEN}" > "${_cfg}" + curl -K "${_cfg}" "$@" + local _rc=$? + rm -f "${_cfg}" + return "${_rc}" +} + # --------------------------------------------------------------------------- # _digitalocean_validate_env # @@ -29,8 +47,7 @@ _digitalocean_validate_env() { return 1 fi - if ! curl -sf \ - -H "Authorization: Bearer ${DO_API_TOKEN}" \ + if ! _do_curl_auth -sf \ "${_DO_API}/account" >/dev/null 2>&1; then log_err "DigitalOcean API authentication failed — check DO_API_TOKEN" return 1 @@ -70,8 +87,7 @@ _digitalocean_provision_verify() { log_step "Checking for droplet ${app}..." local droplets_json - droplets_json=$(curl -sf \ - -H "Authorization: Bearer ${DO_API_TOKEN}" \ + droplets_json=$(_do_curl_auth -sf \ -H "Content-Type: application/json" \ "${_DO_API}/droplets?per_page=200" 2>/dev/null || true) @@ -206,10 +222,9 @@ _digitalocean_teardown() { attempt=$((attempt + 1)) local http_code - http_code=$(curl -s -o /dev/null -w '%{http_code}' \ + http_code=$(_do_curl_auth -s -o /dev/null -w '%{http_code}' \ --max-time 30 \ -X DELETE \ - -H "Authorization: Bearer ${DO_API_TOKEN}" \ -H "Content-Type: application/json" \ "${_DO_API}/droplets/${droplet_id}" 2>/dev/null || printf '000') @@ -232,9 +247,8 @@ _digitalocean_teardown() { local poll_waited=0 while [ "${poll_waited}" -lt 60 ]; do local check_code - check_code=$(curl -s -o /dev/null -w '%{http_code}' \ + check_code=$(_do_curl_auth -s -o /dev/null -w '%{http_code}' \ --max-time 10 \ - -H "Authorization: Bearer ${DO_API_TOKEN}" \ "${_DO_API}/droplets/${droplet_id}" 2>/dev/null || printf '000') if [ "${check_code}" = "404" ]; then @@ -268,8 +282,7 @@ _digitalocean_cleanup_stale() { local max_age=1800 # 30 minutes in seconds local droplets_json - droplets_json=$(curl -sf \ - -H "Authorization: Bearer ${DO_API_TOKEN}" \ + droplets_json=$(_do_curl_auth -sf \ -H "Content-Type: application/json" \ "${_DO_API}/droplets?per_page=200" 2>/dev/null || true) @@ -318,9 +331,8 @@ _digitalocean_cleanup_stale() { age_str=$(format_duration "${age}") log_step "Destroying stale droplet ${droplet_name} (age: ${age_str})" - curl -sf -o /dev/null \ + _do_curl_auth -sf -o /dev/null \ -X DELETE \ - -H "Authorization: Bearer ${DO_API_TOKEN}" \ -H "Content-Type: application/json" \ "${_DO_API}/droplets/${droplet_id}" 2>/dev/null || log_warn "Failed to destroy ${droplet_name}" diff --git a/sh/e2e/lib/clouds/hetzner.sh b/sh/e2e/lib/clouds/hetzner.sh index 0ab5dc88..0de71e81 100644 --- a/sh/e2e/lib/clouds/hetzner.sh +++ b/sh/e2e/lib/clouds/hetzner.sh @@ -7,6 +7,24 @@ set -eo pipefail # --------------------------------------------------------------------------- _HETZNER_API="https://api.hetzner.cloud/v1" +# --------------------------------------------------------------------------- +# _hetzner_curl_auth [curl-args...] +# +# Wrapper around curl that passes the HCLOUD_TOKEN via a temp config file +# instead of a command-line -H flag. This keeps the token out of `ps` output. +# All arguments are forwarded to curl. +# --------------------------------------------------------------------------- +_hetzner_curl_auth() { + local _cfg + _cfg=$(mktemp) + chmod 600 "${_cfg}" + printf 'header = "Authorization: Bearer %s"\n' "${HCLOUD_TOKEN}" > "${_cfg}" + curl -K "${_cfg}" "$@" + local _rc=$? + rm -f "${_cfg}" + return "${_rc}" +} + # --------------------------------------------------------------------------- # _hetzner_validate_env # @@ -19,8 +37,7 @@ _hetzner_validate_env() { return 1 fi - if ! curl -sf \ - -H "Authorization: Bearer ${HCLOUD_TOKEN}" \ + if ! _hetzner_curl_auth -sf \ "${_HETZNER_API}/servers?per_page=1" >/dev/null 2>&1; then log_err "Hetzner API credentials are invalid" return 1 @@ -59,8 +76,7 @@ _hetzner_provision_verify() { encoded_app=$(jq -rn --arg v "${app}" '$v|@uri') local response - response=$(curl -sf \ - -H "Authorization: Bearer ${HCLOUD_TOKEN}" \ + response=$(_hetzner_curl_auth -sf \ "${_HETZNER_API}/servers?name=${encoded_app}" 2>/dev/null || true) if [ -z "${response}" ]; then @@ -181,9 +197,8 @@ _hetzner_teardown() { log_step "Deleting Hetzner server ${app} (id=${server_id})" local http_code - http_code=$(curl -s -o /dev/null -w '%{http_code}' \ + http_code=$(_hetzner_curl_auth -s -o /dev/null -w '%{http_code}' \ -X DELETE \ - -H "Authorization: Bearer ${HCLOUD_TOKEN}" \ "${_HETZNER_API}/servers/${server_id}" 2>/dev/null || printf '000') if [ "${http_code}" = "200" ] || [ "${http_code}" = "204" ]; then @@ -209,8 +224,7 @@ _hetzner_cleanup_stale() { local max_age=1800 # 30 minutes local response - response=$(curl -sf \ - -H "Authorization: Bearer ${HCLOUD_TOKEN}" \ + response=$(_hetzner_curl_auth -sf \ "${_HETZNER_API}/servers?per_page=50" 2>/dev/null || true) if [ -z "${response}" ]; then @@ -266,9 +280,8 @@ _hetzner_cleanup_stale() { log_step "Destroying stale Hetzner server ${server_name} (id=${server_id}, age: ${age_str})" local http_code - http_code=$(curl -s -o /dev/null -w '%{http_code}' \ + http_code=$(_hetzner_curl_auth -s -o /dev/null -w '%{http_code}' \ -X DELETE \ - -H "Authorization: Bearer ${HCLOUD_TOKEN}" \ "${_HETZNER_API}/servers/${server_id}" 2>/dev/null || printf '000') if [ "${http_code}" = "200" ] || [ "${http_code}" = "204" ]; then diff --git a/sh/e2e/lib/provision.sh b/sh/e2e/lib/provision.sh index e993fba0..bf07ec86 100644 --- a/sh/e2e/lib/provision.sh +++ b/sh/e2e/lib/provision.sh @@ -63,7 +63,10 @@ provision_agent() { export OPENROUTER_API_KEY="${OPENROUTER_API_KEY}" # Apply cloud-specific env vars (safe: only processes export VAR="VALUE" lines) - # Uses sed instead of BASH_REMATCH for macOS bash 3.2 compatibility + # Uses sed instead of BASH_REMATCH for macOS bash 3.2 compatibility. + # Positive whitelist: only variables actually emitted by cloud_headless_env + # functions are allowed. This prevents injection of arbitrary env vars. + _ALLOWED_HEADLESS_VARS=" LIGHTSAIL_SERVER_NAME AWS_DEFAULT_REGION LIGHTSAIL_BUNDLE DO_DROPLET_NAME DO_DROPLET_SIZE DO_REGION GCP_INSTANCE_NAME GCP_PROJECT GCP_ZONE GCP_MACHINE_TYPE HETZNER_SERVER_NAME HETZNER_SERVER_TYPE HETZNER_LOCATION " while IFS= read -r _env_line; do # Skip lines that don't look like export VAR="VALUE" case "${_env_line}" in @@ -76,18 +79,14 @@ provision_agent() { if [ -z "${_env_name}" ]; then continue fi - # Block dangerous system env vars that could enable privilege escalation - case "${_env_name}" in - PATH|LD_PRELOAD|LD_LIBRARY_PATH|HOME|SHELL|USER|IFS|ENV|BASH_ENV|CDPATH) - log_err "Blocked dangerous env var: ${_env_name}" + # Only allow whitelisted variable names (positive match) + case "${_ALLOWED_HEADLESS_VARS}" in + *" ${_env_name} "*) ;; + *) + log_err "Rejected unexpected env var from cloud_headless_env: ${_env_name}" continue ;; esac - # Validate env var name matches strict alphanumeric pattern - if ! printf '%s' "${_env_name}" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*$'; then - log_err "Invalid env var name: ${_env_name}" - continue - fi # Validate value against a safe character whitelist BEFORE export if printf '%s' "${_env_val}" | grep -qE '[^A-Za-z0-9@%+=:,./_-]'; then log_err "Invalid characters in env value for ${_env_name}"