made a more secure validation script for rsync
This commit is contained in:
@@ -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*)
|
}
|
||||||
echo "${SSH_ORIGINAL_COMMAND}"
|
|
||||||
;;
|
[[ -n "${SSH_ORIGINAL_COMMAND:-}" ]] || reject "no command"
|
||||||
*)
|
|
||||||
echo "Rejected 3"
|
IFS=' ' read -r -a args <<< "${SSH_ORIGINAL_COMMAND}"
|
||||||
;;
|
[[ ${#args[@]} -ge 1 ]] || reject "empty command"
|
||||||
esac
|
|
||||||
|
# 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[@]}"
|
||||||
|
|||||||
Reference in New Issue
Block a user