From 0e70c4b2ee21233d9eab484f2f02ff6608b80d8e Mon Sep 17 00:00:00 2001 From: Rene Mewissen Date: Tue, 30 Jun 2026 16:46:07 +0200 Subject: [PATCH] made a more secure validation script for rsync --- .../base/files/system_setup/validate-rsync.sh | 113 +++++++++++++++--- 1 file changed, 96 insertions(+), 17 deletions(-) diff --git a/roles/base/files/system_setup/validate-rsync.sh b/roles/base/files/system_setup/validate-rsync.sh index b01561a..f733348 100755 --- a/roles/base/files/system_setup/validate-rsync.sh +++ b/roles/base/files/system_setup/validate-rsync.sh @@ -1,18 +1,97 @@ #!/usr/bin/env bash -case "${SSH_ORIGINAL_COMMAND}" in - *\&*) - echo "Rejected 1" - ;; - *\;*) - echo "Rejected 2" - ;; - rsync*) - ${SSH_ORIGINAL_COMMAND} # no quoting here! - ;; - *true*) - echo "${SSH_ORIGINAL_COMMAND}" - ;; - *) - echo "Rejected 3" - ;; -esac +set -o errexit -o nounset -o noglob -o pipefail + +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 + +reject() { + ${LOGGER} "REJECTED from ${SSH_CONNECTION:-unknown}: ${SSH_ORIGINAL_COMMAND}" + echo "Rejected: ${*}" >&2 + exit 1 +} + +[[ -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}" + 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" + ;; + *) + validated+=("${arg}") + ;; + 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[@]}"