made a more secure validation script for rsync

This commit is contained in:
2026-06-30 16:46:07 +02:00
parent 2f0ad93d75
commit 0e70c4b2ee

View File

@@ -1,18 +1,97 @@
#!/usr/bin/env bash #!/usr/bin/env bash
case "${SSH_ORIGINAL_COMMAND}" in set -o errexit -o nounset -o noglob -o pipefail
*\&*)
echo "Rejected 1" readonly LOGGER="logger -t validate-rsync.sh -p authpriv.warning"
;; READONLY="${READONLY:-false}" # set true to enforce read-only (--sender required)
*\;*) ALLOWED_DIR="${ALLOWED_DIR:-}" # e.g. "/backup" to restrict absolute paths
echo "Rejected 2"
;; reject() {
rsync*) ${LOGGER} "REJECTED from ${SSH_CONNECTION:-unknown}: ${SSH_ORIGINAL_COMMAND}"
${SSH_ORIGINAL_COMMAND} # no quoting here! echo "Rejected: ${*}" >&2
;; exit 1
*true*) }
[[ -n "${SSH_ORIGINAL_COMMAND:-}" ]] || reject "no command"
IFS=' ' read -r -a args <<< "${SSH_ORIGINAL_COMMAND}"
[[ ${#args[@]} -ge 1 ]] || reject "empty command"
# Allow exact "true" as heartbeat check
if [[ ${#args[@]} -eq 1 && "${args[0]}" == "true" ]]; then
echo "${SSH_ORIGINAL_COMMAND}" echo "${SSH_ORIGINAL_COMMAND}"
exit 0
fi
# Must be rsync in server mode
[[ ${#args[@]} -ge 2 ]] || reject "too few arguments"
[[ "${args[0]}" == "rsync" ]] || reject "must start with rsync"
[[ "${args[1]}" == "--server" ]] || reject "must be in server mode"
validated=("rsync" "--server")
for ((i = 2; i < ${#args[@]}; i++)); do
arg="${args[i]}"
# Standalone -e or -M (aliases for --rsh / --remote-option)
if [[ "${arg}" == "-e" || "${arg}" == "-M" ]]; then
reject "standalone ${arg} is not allowed"
fi
# Short option bundle or single short option (e.g. -vlogDtprze.iLsfxC or -v)
if [[ "${arg}" =~ ^-[a-zA-Z0-9.]+$ ]]; then
for ((j = 1; j < ${#arg}; j++)); do
c="${arg:j:1}"
[[ "${c}" =~ [-a-zA-Z0-9.] ]] || reject "illegal short option -${c} in ${arg}"
done
validated+=("${arg}")
continue
fi
# Long option (with optional =value)
if [[ "${arg}" =~ ^--[a-zA-Z_][a-zA-Z0-9_-]*(\=.+)?$ ]]; then
opt="${arg%%=*}"
case "${opt}" in
--rsh|--rsync-path|--remote-option)
reject "${opt} is not allowed"
;;
--files-from|--password-file|--early-input)
reject "${opt} is not allowed"
;;
--write-batch|--only-write-batch|--read-batch)
reject "${opt} is not allowed"
;;
--copy-as)
reject "${opt} is not allowed"
;; ;;
*) *)
echo "Rejected 3" validated+=("${arg}")
;; ;;
esac esac
continue
fi
# Path argument (relative, absolute, or ~-prefixed)
if [[ "${arg}" =~ ^[./~] ]] || [[ "${arg}" =~ ^[[:alnum:]_] ]]; then
if [[ -n "${ALLOWED_DIR}" ]]; then
resolved="$(realpath -m "${arg}" 2>/dev/null || true)"
if [[ -n "${resolved}" && "${resolved}" != "${ALLOWED_DIR}"* ]]; then
reject "path ${arg} resolves outside ${ALLOWED_DIR}"
fi
fi
validated+=("${arg}")
continue
fi
reject "disallowed argument: ${arg}"
done
# Enforce read-only mode
if ${READONLY}; then
has_sender=false
for v in "${validated[@]}"; do
[[ "${v}" == "--sender" ]] && { has_sender=true; break; }
done
${has_sender} || reject "read-only mode requires --sender"
fi
exec "${validated[@]}"