#!/usr/bin/env bash 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" ;; --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[@]}"