Security: Writing Safe Scripts

Chapter 7 — Security: Writing Safe Scripts

Shell scripts are glue code that runs close to the operating system, often with elevated privileges, and routinely handles data from untrusted sources — user input, environment variables, filenames, network responses. A single unquoted variable or careless eval can turn a maintenance script into a privilege-escalation vector. This chapter covers the specific vulnerabilities that affect Bash scripts and the patterns that reliably prevent them.

1 — Injection Vulnerabilities

Shell injection happens when attacker-controlled data is interpreted as shell syntax rather than plain text. The root cause is almost always unquoted variable expansion or dynamic command construction.

Argument injection via unquoted variables

# VULNERABLE: unquoted $filename undergoes word-splitting and globbing
filename="report 2024.pdf"
rm $filename        # runs: rm report 2024.pdf — two args, not one

filename="-rf /"
rm $filename        # runs: rm -rf /  ← catastrophic

# SAFE: always double-quote variable expansions
rm "$filename"

# SAFE: use -- to end option processing before filenames
rm -- "$filename"    # even "-rf /" is treated as a literal filename

Command injection via eval and dynamic commands

# VULNERABLE: user input passed to eval
read -r user_input
eval "echo Hello, $user_input"
# Input: "; rm -rf ~" → runs: echo Hello, ; rm -rf ~

# VULNERABLE: constructing commands by concatenation
cmd="convert $input_file -resize 800x $output_file"
eval "$cmd"
# input_file="x.png -write /etc/passwd x.png" → writes /etc/passwd

# SAFE: pass arguments as separate words — never concatenate into one string
convert "$input_file" -resize 800x "$output_file"

# SAFE: when eval is truly needed, quote with printf %q
eval "myvar=$(printf '%q' "$user_input")"
# printf %q shell-escapes the value so it can only ever be a string literal

Injection via $IFS manipulation

# If an attacker can set IFS, word-splitting changes everywhere
IFS="/"
path="/usr/bin/bash"
for part in $path; do echo "$part"; done   # splits on / not space

# Defence: reset IFS at the top of every script that may run in a
# manipulated environment, or always quote expansions (quotes suppress IFS)
IFS=$' \t\n'   # restore default at script start
The golden rule: every variable that originates outside your script (arguments, environment, files, network, user input) is untrusted. Quote it, validate it, or both — never interpolate it raw into a command string.

2 — Safe Input Validation

# Allowlist approach — only accept known-good patterns
validate_username() {
  local name="$1"
  if [[ "$name" =~ ^[a-zA-Z0-9_-]{1,32}$ ]]; then
    return 0
  else
    printf 'Invalid username: %q\n' "$name" >&2
    return 1
  fi
}

validate_integer() {
  local n="$1"
  local min="${2:--2147483648}"
  local max="${3:-2147483647}"
  [[ "$n" =~ ^-?[0-9]+$ ]] || { echo "not an integer: $n" >&2; return 1; }
  (( n >= min && n <= max )) || { echo "out of range: $n" >&2; return 1; }
}

validate_path() {
  # Reject path traversal and null bytes
  local p="$1"
  local base="${2:-/safe/basedir}"   # required prefix
  [[ "$p" != *".."*  ]] || { echo "traversal attempt: $p" >&2; return 1; }
  [[ "$p" != *$'\0'* ]] || { echo "null byte in path" >&2; return 1; }
  # Resolve to canonical path then check prefix
  local real
  real=$(realpath -m "$p")   # -m: don't require the path to exist
  [[ "$real" == "$base"/* || "$real" == "$base" ]] || {
    echo "path escapes base: $real" >&2; return 1
  }
}

# Always validate before use
validate_username "$1" || exit 1
validate_path "$2" /var/uploads || exit 1

3 — Safe eval Patterns

eval is dangerous but sometimes unavoidable — for example when dynamically constructing variable names or sourcing configuration that must be Bash syntax. These patterns make it as safe as possible.

# printf %q — shell-quote a value so it survives one round of eval
declare -A config
safe_set_var() {
  local varname="$1"
  local value="$2"
  # Validate that varname is a legal identifier
  [[ "$varname" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || {
    echo "invalid variable name: $varname" >&2; return 1
  }
  # printf %q escapes the value; eval sees:  varname='safe value'
  eval "$varname=$(printf '%q' "$value")"
}

safe_set_var greeting "hello; rm -rf ~"
echo "$greeting"   # hello; rm -rf ~  — stored as literal string, not executed

# declare -p round-trip is safe eval (output is already shell-quoted)
arr=( one two three )
serialised=$(declare -p arr)
eval "$serialised"   # safe: declare -p output is quoted by bash itself

# Sourcing config files — risks and mitigations
# Risk: source /user/provided/path.conf runs arbitrary code
# Mitigation 1: check ownership and permissions first
safe_source() {
  local file="$1"
  [[ -f "$file" ]]         || { echo "not a file: $file" >&2; return 1; }
  [[ ! -L "$file" ]]       || { echo "symlink refused: $file" >&2; return 1; }
  # Owned by root or current user, not world-writable
  local owner perms
  read -r perms _ owner _ <<< $(ls -la "$file")
  [[ "$owner" == "root" || "$owner" == "$USER" ]] || {
    echo "refusing to source file owned by $owner" >&2; return 1
  }
  [[ "${perms: -1}" != "w" ]] || {
    echo "world-writable config refused" >&2; return 1
  }
  source "$file"
}

4 — Temporary Files: Doing It Right

# VULNERABLE: predictable temp filename → symlink attack
tmpfile=/tmp/myapp_$$          # attacker creates symlink before we do
echo "secret data" > "$tmpfile"  # writes to attacker's target

# SAFE: mktemp creates a unique file atomically
tmpfile=$(mktemp)                       # /tmp/tmp.XXXXXXXXXX
tmpdir=$(mktemp -d)                     # directory
tmpfile=$(mktemp -t myapp.XXXXXXXXXX)    # custom prefix/template

# ALWAYS register cleanup in a trap so temp files are deleted on exit,
# error, or signal — including SIGINT
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT

# Stacking traps — preserve existing EXIT handler
add_exit_trap() {
  local existing
  existing=$(trap -p EXIT | sed "s/trap -- '//;s/' EXIT//")
  trap "${existing:+${existing};} $1" EXIT
}
add_exit_trap 'rm -f "$tmpfile"'
add_exit_trap 'rm -rf "$tmpdir"'

# Use a private temp directory under $TMPDIR (respects user's temp preference)
WORK=$(mktemp -d "${TMPDIR:-/tmp}/myapp.XXXXXXXXXX")
trap 'rm -rf "$WORK"' EXIT

# Create files inside the private dir — no race risk from /tmp
secrets="${WORK}/secrets"
touch "$secrets"
chmod 600 "$secrets"
printf 'API_KEY=%s\n' "$API_KEY" > "$secrets"

5 — Privilege Separation and sudo

# Principle of least privilege: drop privileges as soon as possible

# Check if running as root — refuse if not expected
if (( EUID == 0 )); then
  echo "Do not run this script as root" >&2; exit 1
fi

# Or require root
if (( EUID != 0 )); then
  exec sudo -- "$0" "$@"   # re-exec ourselves under sudo
fi

# Privilege separation: do the privileged part in a minimal subshell,
# then drop back to the original user for the rest
run_as_root() {
  local user="${SUDO_USER:-$USER}"
  # Only allow specific whitelisted commands
  case "$1" in
    reload-nginx)  systemctl reload nginx ;;
    clear-cache)   find /var/cache/app -delete ;;
    *) echo "unknown privileged action: $1" >&2; return 1 ;;
  esac
}

# sudoers entry for script-specific commands (add via visudo):
# deploy ALL=(root) NOPASSWD: /usr/local/sbin/deploy-helper reload-nginx
# deploy ALL=(root) NOPASSWD: /usr/local/sbin/deploy-helper clear-cache

# NEVER pass user-supplied strings to sudo — validate first
# BAD:
#   sudo systemctl "$user_action" "$user_service"
# GOOD:
case "$user_action" in
  start|stop|restart|status) : ;;
  *) echo "invalid action" >&2; exit 1 ;;
esac
case "$user_service" in
  nginx|redis|postgres) : ;;
  *) echo "invalid service" >&2; exit 1 ;;
esac
sudo systemctl "$user_action" "$user_service"

6 — Secrets Handling

Credentials in scripts are a perennial source of breaches. The most common mistakes are storing secrets in environment variables, command-line arguments, or hardcoded in the script itself.

Why environment variables are risky

# Anyone who can read /proc/PID/environ sees your variables
# ps aux shows command-line arguments — including passwords passed as args

# BAD: hardcoded secret
DB_PASS="hunter2"

# BAD: secret in command-line argument
mysql -u root -phunter2   # visible in ps aux output

# BETTER: read from a file with restricted permissions
DB_PASS_FILE=/etc/myapp/db.secret   # chmod 400, owned by service user
DB_PASS=$(< "$DB_PASS_FILE")

# BETTER: use mysql option file instead of -p flag
# ~/.my.cnf or --defaults-extra-file=/path/to/file
mysql --defaults-extra-file=/etc/myapp/mysql.cnf -u myapp

# BEST: use a secrets manager and only hold in memory briefly
DB_PASS=$(vault kv get -field=password secret/myapp/db)
# Use DB_PASS immediately, then unset it
mysql -u myapp -p"$DB_PASS" -e "SELECT 1"
unset DB_PASS   # remove from environment as soon as possible

Passing secrets to child processes without the environment

# Use process substitution or a temp file to avoid the environment entirely

# Pass a secret via stdin instead of an argument
printf '%s\n' "$DB_PASS" | some-program --password-stdin

# Many tools support reading secrets from a file descriptor
exec 3<<<"$DB_PASS"
some-program --password-fd=3
exec 3<&-

# Scrubbing sensitive variables from the environment before exec'ing untrusted code
unset AWS_SECRET_ACCESS_KEY DB_PASS GITHUB_TOKEN
# Or use env -i to start with a clean environment
env -i HOME="$HOME" PATH="$PATH" untrusted-program

Preventing secrets from appearing in logs and traces

# set -x traces every command — including ones with secrets in variables

# Suppress tracing for a sensitive block
set +x
PASSWORD=$(< /run/secrets/db_password)
set -x   # re-enable if needed

# Redirect xtrace away from the sensitive section
{
  local old_fd=$BASH_XTRACEFD
  exec 20>/dev/null; BASH_XTRACEFD=20
  SENSITIVE=$(read_secret)
  exec 20>&-; BASH_XTRACEFD="$old_fd"
}

7 — Hardening Script Startup

#!/usr/bin/env bash
# Recommended safety header for any script that handles untrusted data
# or runs with elevated privileges

set -euo pipefail
# -e  exit on first error
# -u  treat unset variables as errors (prevents silent empty expansions)
# -o pipefail  pipeline fails if any stage fails (not just the last)

# Restrict PATH to known-good locations — prevents PATH hijacking
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH

# Reset IFS to the default — env may have tampered with it
IFS=$' \t\n'

# Restrict file creation permissions (no group/world write on new files)
umask 077

# Prevent core dumps (may contain secrets)
ulimit -c 0

# Clear dangerous environment variables
unset CDPATH        # can redirect cd to unexpected directories
unset BASH_ENV      # sourced by bash when invoked as sh
unset ENV           # POSIX equivalent of BASH_ENV
unset BASH_XTRACEFD # don't inherit someone else's trace fd

# Absolute path for the script itself (guards against PATH tricks)
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)

8 — Common Vulnerability Checklist

VulnerabilityTriggerFix
Argument injectionUnquoted $var in commandAlways quote: "$var"
Option injectioncmd $var where var starts with -Use cmd -- "$var"
Command injection via evaleval "cmd $userdata"Never eval user data; use printf %q
PATH hijackingScript inherits user's PATHSet explicit PATH at startup
IFS manipulationInherited IFS changes word splittingReset IFS at script start
Symlink attack on temp filesPredictable /tmp/nameUse mktemp; trap EXIT for cleanup
Path traversalUser-supplied path with ..Validate with realpath and prefix check
Credential exposureSecret in env var, CLI arg, or codeUse secret files or a secrets manager; unset after use
World-writable outputDefault umaskumask 077 at startup
Unchecked exit codesContinuing after a failed commandset -euo pipefail
Unset variable expansionrm -rf "$dir/" when $dir is emptyset -u; use ${var:?error}
Glob in filenamesUntrusted filename containing *?[]Quote all filenames; use --
TOCTOU (check-then-use)Testing file, then acting — race windowUse atomic operations; noclobber, flock

Exercises

Exercise 1 — Vulnerability hunt

The following script contains at least six distinct security vulnerabilities. Identify each one, name the vulnerability class, and provide the corrected line. Then rewrite the complete function safely.

deploy_release() {
  version=$1
  deploy_dir="/var/www/$version"
  archive="/tmp/$version.tar.gz"
  curl -so $archive "https://releases.example.com/$version.tar.gz"
  tar -xzf $archive -C $deploy_dir
  config=$(cat /etc/myapp/deploy.conf)
  eval "$config"
  chmod -R 777 "$deploy_dir"
  echo "Deployed $version with DB_PASS=$DB_PASS"
}
# Vulnerabilities:
# 1. $1 not quoted → word splitting/glob if version contains spaces or special chars
# 2. $archive unquoted in curl → argument injection (also predictable /tmp path)
# 3. $archive and $deploy_dir unquoted in tar → argument injection
# 4. eval "$config" → arbitrary code execution from config file
# 5. chmod -R 777 → world-writable deployed files
# 6. echo "... DB_PASS=$DB_PASS" → credential leakage in logs/terminal

deploy_release() {
  local version="${1:?version required}"

  # 1. Validate version is a safe identifier (no path traversal, no injection)
  [[ "$version" =~ ^[a-zA-Z0-9._-]+$ ]] || {
    printf 'Invalid version: %q\n' "$version" >&2; return 1
  }

  # 2. Use mktemp for the archive — not a predictable /tmp path
  local archive
  archive=$(mktemp -t deploy.XXXXXXXXXX.tar.gz)
  trap 'rm -f "$archive"' RETURN

  # 3. Quote all variables used in commands
  local deploy_dir="/var/www/$version"
  # Validate deploy_dir stays within /var/www/
  local real_dir
  real_dir=$(realpath -m "$deploy_dir")
  [[ "$real_dir" == "/var/www/"* ]] || {
    echo "deploy_dir escapes /var/www/" >&2; return 1
  }

  curl -so "$archive" "https://releases.example.com/$version.tar.gz"
  tar -xzf "$archive" -C "$deploy_dir"

  # 4. Source config only after safety checks — never eval raw file content
  safe_source /etc/myapp/deploy.conf

  # 5. Least-privilege permissions
  chmod -R 755 "$deploy_dir"
  find "$deploy_dir" -name '*.conf' -exec chmod 640 {} +

  # 6. Never log credentials
  printf 'Deployed %s successfully\n' "$version"
}

Exercise 2 — Safe argument parser

Write a function safe_cli_parser that processes these arguments: --user NAME, --days N, --output FILE, and an optional --verbose flag. Enforce:

  • NAME matches ^[a-zA-Z0-9_-]{1,32}$
  • N is an integer between 1 and 365
  • FILE resolves inside /var/reports/ — reject traversal
  • Unknown arguments cause an error with a usage message
  • All three required arguments must be provided

Demonstrate it correctly rejecting: --user "admin; rm -rf /", --days 999, and --output "../../etc/passwd".

safe_cli_parser() {
  local user="" days="" output="" verbose=0

  while (( $# > 0 )); do
    case "$1" in
      --user)
        [[ $# -ge 2 ]] || { echo "--user requires a value" >&2; return 1; }
        [[ "$2" =~ ^[a-zA-Z0-9_-]{1,32}$ ]] || {
          printf 'Invalid --user value: %q\n' "$2" >&2; return 1
        }
        user="$2"; shift 2 ;;
      --days)
        [[ $# -ge 2 ]] || { echo "--days requires a value" >&2; return 1; }
        [[ "$2" =~ ^[0-9]+$ ]] && (( 10#$2 >= 1 && 10#$2 <= 365 )) || {
          printf '--days must be 1-365, got: %q\n' "$2" >&2; return 1
        }
        days="$2"; shift 2 ;;
      --output)
        [[ $# -ge 2 ]] || { echo "--output requires a value" >&2; return 1; }
        local real_out
        real_out=$(realpath -m "$2")
        [[ "$real_out" == "/var/reports/"* ]] || {
          printf '--output must be inside /var/reports/, got: %q\n' \
            "$real_out" >&2; return 1
        }
        output="$real_out"; shift 2 ;;
      --verbose) verbose=1; shift ;;
      *)
        printf 'Unknown argument: %q\n' "$1" >&2
        printf 'Usage: cmd --user NAME --days N --output FILE [--verbose]\n' >&2
        return 1 ;;
    esac
  done

  for req in user days output; do
    [[ -n "${!req}" ]] || {
      printf '--%s is required\n' "$req" >&2; return 1
    }
  done

  printf 'OK: user=%s days=%s output=%s verbose=%d\n' \
    "$user" "$days" "$output" "$verbose"
}

# Rejection tests
safe_cli_parser --user "admin; rm -rf /" --days 7   --output /var/reports/r.txt
safe_cli_parser --user alice               --days 999 --output /var/reports/r.txt
safe_cli_parser --user alice               --days 7   --output ../../etc/passwd
# All three should print errors and return 1
safe_cli_parser --user alice               --days 30  --output /var/reports/alice.csv --verbose
# Should print: OK: user=alice days=30 output=/var/reports/alice.csv verbose=1

Exercise 3 — Secure secret loader

Write a load_secrets FILE function that reads a KEY=VALUE config file and exports only the listed variables. Requirements:

  • Reject the file if it is world-readable (chmod o-r check)
  • Reject the file if it is a symlink
  • Reject the file if it is not owned by the current user or root
  • Parse lines manually — do not source or eval the file
  • Accept only keys matching ^[A-Z_][A-Z0-9_]*$ and skip malformed lines with a warning
  • Strip single and double quotes from values
  • After loading, overwrite the variable storing the file path with an empty string and unset it
load_secrets() {
  local file="$1"

  # Existence
  [[ -f "$file" ]] || { printf 'load_secrets: not a file: %s\n' "$file" >&2; return 1; }

  # Not a symlink
  [[ ! -L "$file" ]] || { printf 'load_secrets: symlinks refused: %s\n' "$file" >&2; return 1; }

  # Permissions: not world-readable
  local mode owner
  read -r mode _ owner <<< $(stat -c '%a %U' "$file")
  (( 10#$mode % 10 == 0 )) || {
    printf 'load_secrets: file is world-readable (mode %s): %s\n' \
      "$mode" "$file" >&2; return 1
  }

  # Ownership: current user or root
  [[ "$owner" == "$USER" || "$owner" == "root" ]] || {
    printf 'load_secrets: file owned by %s, expected %s or root\n' \
      "$owner" "$USER" >&2; return 1
  }

  # Parse manually — no eval/source
  local lineno=0 key value raw
  while IFS= read -r raw || [[ -n "$raw" ]]; do
    (( lineno++ ))
    # Skip blank lines and comments
    [[ $raw =~ ^[[:space:]]*(#|$) ]] && continue
    # Must be KEY=VALUE
    [[ $raw =~ ^([A-Z_][A-Z0-9_]*)=(.*) ]] || {
      printf 'load_secrets: skipping malformed line %d: %q\n' \
        "$lineno" "$raw" >&2; continue
    }
    key="${BASH_REMATCH[1]}"
    value="${BASH_REMATCH[2]}"
    # Strip surrounding single or double quotes
    value="${value#\"}"; value="${value%\"}"
    value="${value#\'}"; value="${value%\'}"
    # Export without eval
    export "$key"="$value"
  done < "$file"

  # Scrub the file path variable
  file=""
  unset file
}

# Test
touch /tmp/test_secrets.env
chmod 600 /tmp/test_secrets.env
printf 'DB_PASS="s3cr3t"\nAPI_KEY=abc123\nBAD LINE\n# comment\n' \
  > /tmp/test_secrets.env
load_secrets /tmp/test_secrets.env
echo "DB_PASS=$DB_PASS API_KEY=$API_KEY"
rm /tmp/test_secrets.env

Exercise 4 — Hardened script template

Write a complete, production-ready Bash script skeleton called hardened_template.sh that incorporates every defence from this chapter. It should:

  • Set all recommended startup options (set -euo pipefail, fixed PATH, IFS, umask, ulimit)
  • Unset dangerous environment variables
  • Declare a die CODE MESSAGE helper and a cleanup EXIT trap
  • Validate at least one argument using an allowlist regex
  • Create a private temp directory via mktemp -d registered in the EXIT trap
  • Demonstrate safe secret loading (read from a file, never echo)
  • Include a --dry-run flag that suppresses all destructive operations
  • Be fully ShellCheck-clean (no SC warnings)
#!/usr/bin/env bash
# hardened_template.sh — production-ready secure script skeleton
set -euo pipefail

# ── Security hardening ──────────────────────────────────────────────
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH
IFS=$' \t\n'
umask 077
ulimit -c 0
unset CDPATH BASH_ENV ENV

# ── Script identity ─────────────────────────────────────────────────
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
SCRIPT_NAME="${0##*/}"

# ── Globals ─────────────────────────────────────────────────────────
DRY_RUN=0
WORK=""

# ── Helpers ─────────────────────────────────────────────────────────
die() {
  local code="$1"; shift
  printf '%s: ERROR: %s\n' "$SCRIPT_NAME" "$*" >&2
  exit "$code"
}

log() { printf '[%(%Y-%m-%d %H:%M:%S)T] %s\n' -1 "$*"; }

cleanup() {
  [[ -n "$WORK" ]] && rm -rf "$WORK"
}
trap cleanup EXIT

# ── Argument parsing ────────────────────────────────────────────────
usage() {
  printf 'Usage: %s [--dry-run] --env (prod|staging|dev)\n' "$SCRIPT_NAME" >&2
  exit 1
}

TARGET_ENV=""
while (( $# > 0 )); do
  case "$1" in
    --dry-run)  DRY_RUN=1; shift ;;
    --env)
      [[ $# -ge 2 ]] || die 1 "--env requires a value"
      [[ "$2" =~ ^(prod|staging|dev)$ ]] ||
        die 1 "--env must be prod, staging, or dev; got: $(printf '%q' "$2")"
      TARGET_ENV="$2"; shift 2 ;;
    -h|--help) usage ;;
    *) die 1 "Unknown argument: $(printf '%q' "$1")" ;;
  esac
done
[[ -n "$TARGET_ENV" ]] || die 1 "--env is required"

# ── Setup ───────────────────────────────────────────────────────────
WORK=$(mktemp -d "${TMPDIR:-/tmp}/${SCRIPT_NAME%.sh}.XXXXXXXXXX")
log "Work directory: $WORK"

# ── Secret loading (no eval, no echo) ───────────────────────────────
SECRET_FILE="${SCRIPT_DIR}/secrets/${TARGET_ENV}.env"
if [[ -f "$SECRET_FILE" ]]; then
  while IFS='=' read -r k v || [[ -n "$k" ]]; do
    [[ $k =~ ^[A-Z_][A-Z0-9_]*$ ]] || continue
    v="${v#\"}"; v="${v%\"}"
    export "$k"="$v"
  done < "$SECRET_FILE"
fi

# ── Main work ───────────────────────────────────────────────────────
log "Starting deployment to $TARGET_ENV"
if (( DRY_RUN )); then
  log "[DRY RUN] would deploy to $TARGET_ENV — skipping destructive steps"
else
  log "Deploying..."
  # ... real work here ...
fi

log "Done"