116 lines
3.2 KiB
Bash
116 lines
3.2 KiB
Bash
#!/bin/bash
|
|
# restricted_deploy.sh — Forced SSH command for per-team deployment isolation.
|
|
#
|
|
# Installed on UAT at /usr/local/bin/restricted_deploy.sh.
|
|
# Referenced in deployer's authorized_keys as:
|
|
# command="/usr/local/bin/restricted_deploy.sh <team>",no-port-forwarding,... ssh-rsa ...
|
|
#
|
|
# Only allows:
|
|
# - scp sink mode (write): scp -t [-r] [-d] /srv/deployments/<team>/...
|
|
# - mkdir: mkdir -p /srv/deployments/<team>/...
|
|
|
|
set -euo pipefail
|
|
|
|
TEAM_NAME="$1"
|
|
BASE_DIR="/srv/deployments"
|
|
ALLOWED_DIR="${BASE_DIR}/${TEAM_NAME}"
|
|
|
|
if [ ! -d "$ALLOWED_DIR" ]; then
|
|
echo "ERROR: team directory does not exist" >&2
|
|
exit 1
|
|
fi
|
|
|
|
ALLOWED_REAL=$(readlink -f "$ALLOWED_DIR")
|
|
|
|
ORIG_CMD="${SSH_ORIGINAL_COMMAND:-}"
|
|
if [ -z "$ORIG_CMD" ]; then
|
|
echo "ERROR: interactive shell access denied" >&2
|
|
exit 1
|
|
fi
|
|
|
|
CMD_VERB=$(echo "$ORIG_CMD" | awk '{print $1}')
|
|
|
|
case "$CMD_VERB" in
|
|
scp)
|
|
# Extract the target path (always the last argument)
|
|
TARGET_PATH=$(echo "$ORIG_CMD" | awk '{print $NF}')
|
|
|
|
# Validate flags: only -t, -r, -d are allowed
|
|
FLAGS=$(echo "$ORIG_CMD" | awk '{$1=""; $NF=""; print}' | tr -s ' ')
|
|
for flag in $FLAGS; do
|
|
case "$flag" in
|
|
-t|-r|-d) ;;
|
|
*) echo "ERROR: disallowed scp flag: $flag" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
# Must contain -t (sink/write mode)
|
|
if ! echo "$ORIG_CMD" | grep -q -- '-t'; then
|
|
echo "ERROR: only scp sink mode (-t) is allowed" >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
mkdir)
|
|
if ! echo "$ORIG_CMD" | grep -q '^mkdir -p '; then
|
|
echo "ERROR: only 'mkdir -p <path>' is allowed" >&2
|
|
exit 1
|
|
fi
|
|
TARGET_PATH=$(echo "$ORIG_CMD" | awk '{print $NF}')
|
|
;;
|
|
*)
|
|
echo "ERROR: command not allowed: $CMD_VERB" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
# ---- Path validation (anti-traversal) ----
|
|
|
|
# Reject paths containing ".."
|
|
if echo "$TARGET_PATH" | grep -q '\.\.'; then
|
|
echo "ERROR: path traversal detected" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Textual prefix check
|
|
case "$TARGET_PATH" in
|
|
"${ALLOWED_DIR}/"*|"${ALLOWED_DIR}")
|
|
;;
|
|
*)
|
|
echo "ERROR: target path is outside allowed directory" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
# Canonical check for existing paths (defeats symlink escapes)
|
|
if [ -e "$TARGET_PATH" ]; then
|
|
REAL_TARGET=$(readlink -f "$TARGET_PATH")
|
|
case "$REAL_TARGET" in
|
|
"${ALLOWED_REAL}/"*|"${ALLOWED_REAL}")
|
|
;;
|
|
*)
|
|
echo "ERROR: resolved path is outside allowed directory" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
else
|
|
# For non-existent paths (mkdir case), resolve the nearest existing parent
|
|
CHECK_PATH="$TARGET_PATH"
|
|
while [ ! -e "$CHECK_PATH" ] && [ "$CHECK_PATH" != "/" ]; do
|
|
CHECK_PATH=$(dirname "$CHECK_PATH")
|
|
done
|
|
if [ -e "$CHECK_PATH" ]; then
|
|
REAL_CHECK=$(readlink -f "$CHECK_PATH")
|
|
case "$REAL_CHECK" in
|
|
"${ALLOWED_REAL}/"*|"${ALLOWED_REAL}")
|
|
;;
|
|
*)
|
|
echo "ERROR: resolved parent path is outside allowed directory" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
fi
|
|
fi
|
|
|
|
# All checks passed — execute the original command
|
|
exec $ORIG_CMD
|