Fix security and correctness bugs found in code review
- Add shquote() to escape single quotes in paths passed to remote_exec, preventing shell injection via REMOTE_BASE containing single quotes - Apply shquote to remote_exec calls in remotes.sh, backup.sh, transfer.sh, ssh.sh - Add DISK_USAGE_THRESHOLD validation in config.sh - Export SMTP_PASSWORD (was missing from export list) - Fix WEB_PORT default mismatch: use 2323 consistently in from_conf and settings save - Narrow exception catch in remotes.py disk info fetch to KeyError/LookupError - Quote REMOTE_KEY in build_rsync_ssh_cmd for paths with spaces Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -181,7 +181,8 @@ METAEOF
|
|||||||
elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then
|
elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then
|
||||||
echo "$meta_json" > "$snap_dir/${ts}.partial/meta.json" || log_warn "Failed to write meta.json"
|
echo "$meta_json" > "$snap_dir/${ts}.partial/meta.json" || log_warn "Failed to write meta.json"
|
||||||
else
|
else
|
||||||
echo "$meta_json" | remote_exec "cat > '$snap_dir/${ts}.partial/meta.json'" || log_warn "Failed to write meta.json"
|
local sq_partial; sq_partial="$(shquote "$snap_dir/${ts}.partial")"
|
||||||
|
echo "$meta_json" | remote_exec "cat > '${sq_partial}/meta.json'" || log_warn "Failed to write meta.json"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 11. Generate manifest.txt
|
# 11. Generate manifest.txt
|
||||||
@@ -193,7 +194,8 @@ METAEOF
|
|||||||
elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then
|
elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then
|
||||||
find "$snap_dir/${ts}.partial" -type f 2>/dev/null > "$snap_dir/${ts}.partial/manifest.txt" || log_warn "Failed to write manifest.txt"
|
find "$snap_dir/${ts}.partial" -type f 2>/dev/null > "$snap_dir/${ts}.partial/manifest.txt" || log_warn "Failed to write manifest.txt"
|
||||||
else
|
else
|
||||||
remote_exec "find '$snap_dir/${ts}.partial' -type f > '$snap_dir/${ts}.partial/manifest.txt'" 2>/dev/null || log_warn "Failed to write manifest.txt"
|
local sq_partial; sq_partial="$(shquote "$snap_dir/${ts}.partial")"
|
||||||
|
remote_exec "find '${sq_partial}' -type f > '${sq_partial}/manifest.txt'" 2>/dev/null || log_warn "Failed to write manifest.txt"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 12. Finalize snapshot
|
# 12. Finalize snapshot
|
||||||
@@ -211,7 +213,8 @@ METAEOF
|
|||||||
elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then
|
elif [[ "${REMOTE_TYPE:-ssh}" == "local" ]]; then
|
||||||
total_size=$(du -sb "$snap_dir/$ts" 2>/dev/null | cut -f1) || total_size=0
|
total_size=$(du -sb "$snap_dir/$ts" 2>/dev/null | cut -f1) || total_size=0
|
||||||
else
|
else
|
||||||
total_size=$(remote_exec "du -sb '$snap_dir/$ts' 2>/dev/null | cut -f1" 2>/dev/null) || total_size=0
|
local sq_snap; sq_snap="$(shquote "$snap_dir/$ts")"
|
||||||
|
total_size=$(remote_exec "du -sb '$sq_snap' 2>/dev/null | cut -f1" 2>/dev/null) || total_size=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_info "Backup completed for $target_name: $ts ($(human_size "${total_size:-0}") in $(human_duration "$duration"))"
|
log_info "Backup completed for $target_name: $ts ($(human_size "${total_size:-0}") in $(human_duration "$duration"))"
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ load_config() {
|
|||||||
|
|
||||||
export BACKUP_MODE BWLIMIT RETENTION_COUNT
|
export BACKUP_MODE BWLIMIT RETENTION_COUNT
|
||||||
export LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON
|
export LOG_LEVEL LOG_RETAIN NOTIFY_EMAIL NOTIFY_ON
|
||||||
export SMTP_HOST SMTP_PORT SMTP_USER SMTP_FROM SMTP_SECURITY
|
export SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASSWORD SMTP_FROM SMTP_SECURITY
|
||||||
export SSH_TIMEOUT SSH_RETRIES RSYNC_EXTRA_OPTS DISK_USAGE_THRESHOLD
|
export SSH_TIMEOUT SSH_RETRIES RSYNC_EXTRA_OPTS DISK_USAGE_THRESHOLD
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +128,11 @@ validate_config() {
|
|||||||
((errors++)) || true
|
((errors++)) || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${DISK_USAGE_THRESHOLD:-}" ]] && [[ ! "$DISK_USAGE_THRESHOLD" =~ ^[0-9]+$ ]]; then
|
||||||
|
log_error "DISK_USAGE_THRESHOLD must be a non-negative integer (0-100), got: $DISK_USAGE_THRESHOLD"
|
||||||
|
((errors++)) || true
|
||||||
|
fi
|
||||||
|
|
||||||
# Validate RSYNC_EXTRA_OPTS characters (prevent flag injection)
|
# Validate RSYNC_EXTRA_OPTS characters (prevent flag injection)
|
||||||
if [[ -n "${RSYNC_EXTRA_OPTS:-}" ]] && [[ ! "$RSYNC_EXTRA_OPTS" =~ ^[a-zA-Z0-9\ ._=/,-]+$ ]]; then
|
if [[ -n "${RSYNC_EXTRA_OPTS:-}" ]] && [[ ! "$RSYNC_EXTRA_OPTS" =~ ^[a-zA-Z0-9\ ._=/,-]+$ ]]; then
|
||||||
log_error "RSYNC_EXTRA_OPTS contains invalid characters: $RSYNC_EXTRA_OPTS"
|
log_error "RSYNC_EXTRA_OPTS contains invalid characters: $RSYNC_EXTRA_OPTS"
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ get_target_remotes() {
|
|||||||
# Return the disk usage percentage (integer, no %) for REMOTE_BASE.
|
# Return the disk usage percentage (integer, no %) for REMOTE_BASE.
|
||||||
# Returns 0 (unknown) on unsupported remote types.
|
# Returns 0 (unknown) on unsupported remote types.
|
||||||
remote_disk_usage_pct() {
|
remote_disk_usage_pct() {
|
||||||
local base="${REMOTE_BASE:-/}"
|
local base; base="$(shquote "${REMOTE_BASE:-/}")"
|
||||||
local df_line=""
|
local df_line=""
|
||||||
case "${REMOTE_TYPE:-ssh}" in
|
case "${REMOTE_TYPE:-ssh}" in
|
||||||
ssh)
|
ssh)
|
||||||
@@ -332,7 +332,7 @@ check_remote_disk_space() {
|
|||||||
|
|
||||||
# Compact one-line disk info: "USED/TOTAL (FREE free) PCT"
|
# Compact one-line disk info: "USED/TOTAL (FREE free) PCT"
|
||||||
remote_disk_info_short() {
|
remote_disk_info_short() {
|
||||||
local base="${REMOTE_BASE:-/}"
|
local base; base="$(shquote "${REMOTE_BASE:-/}")"
|
||||||
local df_out=""
|
local df_out=""
|
||||||
case "${REMOTE_TYPE:-ssh}" in
|
case "${REMOTE_TYPE:-ssh}" in
|
||||||
ssh)
|
ssh)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ test_ssh_connection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensure_remote_dir() {
|
ensure_remote_dir() {
|
||||||
local dir="$1"
|
local dir; dir="$(shquote "$1")"
|
||||||
remote_exec "mkdir -p '$dir'" || {
|
remote_exec "mkdir -p '$dir'" || {
|
||||||
log_error "Failed to create remote directory: $dir"
|
log_error "Failed to create remote directory: $dir"
|
||||||
return 1
|
return 1
|
||||||
@@ -75,6 +75,6 @@ build_rsync_ssh_cmd() {
|
|||||||
if _is_password_mode; then
|
if _is_password_mode; then
|
||||||
echo "ssh -p $REMOTE_PORT -o StrictHostKeyChecking=yes -o ConnectTimeout=$SSH_TIMEOUT"
|
echo "ssh -p $REMOTE_PORT -o StrictHostKeyChecking=yes -o ConnectTimeout=$SSH_TIMEOUT"
|
||||||
else
|
else
|
||||||
echo "ssh -i $REMOTE_KEY -p $REMOTE_PORT -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=$SSH_TIMEOUT"
|
echo "ssh -i \"$REMOTE_KEY\" -p $REMOTE_PORT -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=$SSH_TIMEOUT"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,9 @@ finalize_snapshot() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
remote_exec "mv '$snap_dir/${timestamp}.partial' '$snap_dir/$timestamp'" || {
|
local sq_partial; sq_partial="$(shquote "$snap_dir/${timestamp}.partial")"
|
||||||
|
local sq_final; sq_final="$(shquote "$snap_dir/$timestamp")"
|
||||||
|
remote_exec "mv '$sq_partial' '$sq_final'" || {
|
||||||
log_error "Failed to finalize snapshot for $target_name: $timestamp"
|
log_error "Failed to finalize snapshot for $target_name: $timestamp"
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ die() {
|
|||||||
exit "$code"
|
exit "$code"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Escape a string for safe use inside single quotes in shell commands.
|
||||||
|
# Usage: shquote "$var" → outputs the value with ' escaped to '\''
|
||||||
|
shquote() {
|
||||||
|
printf '%s' "$1" | sed "s/'/'\\\\''/g"
|
||||||
|
}
|
||||||
|
|
||||||
timestamp() {
|
timestamp() {
|
||||||
date -u +"%Y-%m-%dT%H%M%S"
|
date -u +"%Y-%m-%dT%H%M%S"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class AppSettings:
|
|||||||
rsync_extra_opts=data.get("RSYNC_EXTRA_OPTS", ""),
|
rsync_extra_opts=data.get("RSYNC_EXTRA_OPTS", ""),
|
||||||
disk_usage_threshold=data.get("DISK_USAGE_THRESHOLD", "95"),
|
disk_usage_threshold=data.get("DISK_USAGE_THRESHOLD", "95"),
|
||||||
work_dir=data.get("WORK_DIR", "/usr/local/gniza/workdir"),
|
work_dir=data.get("WORK_DIR", "/usr/local/gniza/workdir"),
|
||||||
web_port=data.get("WEB_PORT", "8080"),
|
web_port=data.get("WEB_PORT", "2323"),
|
||||||
web_host=data.get("WEB_HOST", "0.0.0.0"),
|
web_host=data.get("WEB_HOST", "0.0.0.0"),
|
||||||
web_api_key=data.get("WEB_API_KEY", ""),
|
web_api_key=data.get("WEB_API_KEY", ""),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ class RemotesScreen(Screen):
|
|||||||
try:
|
try:
|
||||||
table = self.query_one("#remotes-table", DataTable)
|
table = self.query_one("#remotes-table", DataTable)
|
||||||
table.update_cell(name, self._disk_col_key, disk_text, update_width=True)
|
table.update_cell(name, self._disk_col_key, disk_text, update_width=True)
|
||||||
except Exception:
|
except (KeyError, LookupError):
|
||||||
|
# Row may have been removed if user navigated away and back
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@work
|
@work
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class SettingsScreen(Screen):
|
|||||||
rsync_extra_opts=self.query_one("#set-rsyncopts", Input).value.strip(),
|
rsync_extra_opts=self.query_one("#set-rsyncopts", Input).value.strip(),
|
||||||
disk_usage_threshold=self.query_one("#set-diskthreshold", Input).value.strip() or "95",
|
disk_usage_threshold=self.query_one("#set-diskthreshold", Input).value.strip() or "95",
|
||||||
work_dir=self.query_one("#set-workdir", Input).value.strip() or "/usr/local/gniza/workdir",
|
work_dir=self.query_one("#set-workdir", Input).value.strip() or "/usr/local/gniza/workdir",
|
||||||
web_port=self.query_one("#set-web-port", Input).value.strip() or "8080",
|
web_port=self.query_one("#set-web-port", Input).value.strip() or "2323",
|
||||||
web_host=self.query_one("#set-web-host", Input).value.strip() or "0.0.0.0",
|
web_host=self.query_one("#set-web-host", Input).value.strip() or "0.0.0.0",
|
||||||
web_api_key=self.query_one("#set-web-key", Input).value,
|
web_api_key=self.query_one("#set-web-key", Input).value,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user