Merge pull request #8591 from dokku/arbitrary-file-write

Harden archive extraction against symlink traversal
This commit is contained in:
Jose Diaz-Gonzalez
2026-05-09 15:58:31 -04:00
committed by GitHub
9 changed files with 511 additions and 6 deletions

View File

@@ -44,7 +44,7 @@ cmd-certs-set() {
local CERTS_SET_TMP_WORK_DIR=$(mktemp -d "/tmp/dokku-${DOKKU_PID}-${FUNCNAME[0]}.XXXXXX")
pushd "$CERTS_SET_TMP_WORK_DIR" &>/dev/null
trap "popd &>/dev/null || true; rm -rf '$CERTS_SET_TMP_WORK_DIR' >/dev/null" RETURN
tar xvf - <&0
fn-archive-extract-tar-stdin "$CERTS_SET_TMP_WORK_DIR" "certs:add"
local CRT_FILE_SEARCH=$(find . -not -path '*/\.*' -type f | grep ".crt$")
local CRT_FILE_COUNT=$(printf "%s" "$CRT_FILE_SEARCH" | grep -c '^')

219
plugins/common/archive-functions Executable file
View File

@@ -0,0 +1,219 @@
#!/usr/bin/env bash
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
if ! declare -F fn-plugin-property-get >/dev/null 2>&1; then
if [[ -f "${PLUGIN_CORE_AVAILABLE_PATH:-/var/lib/dokku/core-plugins/available}/common/property-functions" ]]; then
source "${PLUGIN_CORE_AVAILABLE_PATH:-/var/lib/dokku/core-plugins/available}/common/property-functions"
fi
fi
DOKKU_ARCHIVE_MAX_SIZE_DEFAULT="1073741824"
DOKKU_ARCHIVE_MAX_FILES_DEFAULT="10000"
fn-archive-max-size() {
declare desc="returns the configured max archive size in bytes"
fn-plugin-property-get "git" "--global" "archive-max-size" "$DOKKU_ARCHIVE_MAX_SIZE_DEFAULT"
}
fn-archive-max-files() {
declare desc="returns the configured max archive entry count"
fn-plugin-property-get "git" "--global" "archive-max-files" "$DOKKU_ARCHIVE_MAX_FILES_DEFAULT"
}
fn-archive-log-security-event() {
declare desc="logs an archive security event for auditing"
declare EVENT="$1" ARCHIVE_TYPE="$2" SOURCE="$3" DETAILS="$4"
local TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
local USERNAME="${SUDO_USER:-${USER:-unknown}}"
echo "[archive-security] timestamp=${TIMESTAMP} user=${USERNAME} event=${EVENT} archive_type=${ARCHIVE_TYPE} source=${SOURCE} details=${DETAILS}" 1>&2
}
fn-archive-tar-flag() {
declare desc="returns the appropriate decompression flag for tar based on archive type"
declare ARCHIVE_TYPE="$1"
case "$ARCHIVE_TYPE" in
tar) echo "" ;;
tar.gz) echo "-z" ;;
*) return 1 ;;
esac
}
fn-archive-check-bomb-protection() {
declare desc="validates archive against bomb protection limits"
declare ARCHIVE_PATH="$1" ARCHIVE_TYPE="$2"
local MAX_SIZE MAX_FILES
MAX_SIZE="$(fn-archive-max-size)"
MAX_FILES="$(fn-archive-max-files)"
local ARCHIVE_SIZE
ARCHIVE_SIZE="$(stat -c %s "$ARCHIVE_PATH" 2>/dev/null || stat -f %z "$ARCHIVE_PATH" 2>/dev/null || echo 0)"
if [[ "$ARCHIVE_SIZE" -gt "$MAX_SIZE" ]]; then
fn-archive-log-security-event "rejected_archive_too_large" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "size=${ARCHIVE_SIZE} max=${MAX_SIZE}"
dokku_log_fail "Archive exceeds maximum allowed size of ${MAX_SIZE} bytes"
fi
local TAR_FLAG
TAR_FLAG="$(fn-archive-tar-flag "$ARCHIVE_TYPE")" || return 0
local FILE_COUNT
if [[ -n "$TAR_FLAG" ]]; then
FILE_COUNT="$(tar "$TAR_FLAG" -tf "$ARCHIVE_PATH" 2>/dev/null | wc -l)"
else
FILE_COUNT="$(tar -tf "$ARCHIVE_PATH" 2>/dev/null | wc -l)"
fi
if [[ "$FILE_COUNT" -gt "$MAX_FILES" ]]; then
fn-archive-log-security-event "rejected_archive_too_many_files" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "count=${FILE_COUNT} max=${MAX_FILES}"
dokku_log_fail "Archive contains ${FILE_COUNT} entries which exceeds the maximum of ${MAX_FILES}"
fi
}
fn-archive-validate-tar-entries() {
declare desc="pre-scans a tar archive for dangerous path entries"
declare ARCHIVE_PATH="$1" ARCHIVE_TYPE="$2"
local TAR_FLAG NAMES VERBOSE
TAR_FLAG="$(fn-archive-tar-flag "$ARCHIVE_TYPE")" || dokku_log_fail "Unsupported archive type for validation: ${ARCHIVE_TYPE}"
if [[ -n "$TAR_FLAG" ]]; then
NAMES="$(tar "$TAR_FLAG" -tf "$ARCHIVE_PATH" 2>/dev/null)"
VERBOSE="$(tar "$TAR_FLAG" -tvf "$ARCHIVE_PATH" 2>/dev/null)"
else
NAMES="$(tar -tf "$ARCHIVE_PATH" 2>/dev/null)"
VERBOSE="$(tar -tvf "$ARCHIVE_PATH" 2>/dev/null)"
fi
if echo "$NAMES" | grep -qE '^/'; then
fn-archive-log-security-event "rejected_absolute_path" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "absolute path detected"
dokku_log_fail "Archive contains entries with absolute paths"
fi
if echo "$NAMES" | grep -qE '(^|/)\.\.(/|$)'; then
fn-archive-log-security-event "rejected_path_traversal" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "parent directory traversal detected"
dokku_log_fail "Archive contains entries with parent directory traversal"
fi
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
[[ "${entry:0:1}" != "l" ]] && continue
local target="${entry##*-> }"
case "$target" in
/*)
fn-archive-log-security-event "rejected_unsafe_symlink" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "absolute symlink target=${target}"
dokku_log_fail "Archive contains symlinks with absolute targets"
;;
*..*)
fn-archive-log-security-event "rejected_unsafe_symlink" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "traversal symlink target=${target}"
dokku_log_fail "Archive contains symlinks pointing outside extraction directory"
;;
esac
done <<<"$VERBOSE"
}
fn-archive-validate-extracted-symlinks() {
declare desc="validates that no symlinks in the extraction directory point outside it"
declare EXTRACTION_DIR="$1"
local CANONICAL_DIR
CANONICAL_DIR="$(readlink -f "$EXTRACTION_DIR")"
local SYMLINKS
SYMLINKS="$(find "$EXTRACTION_DIR" -type l 2>/dev/null || true)"
[[ -z "$SYMLINKS" ]] && return 0
while IFS= read -r link; do
[[ -z "$link" ]] && continue
local target
target="$(readlink -f "$link" 2>/dev/null || echo "")"
if [[ -z "$target" ]] || [[ "$target" != "$CANONICAL_DIR"* ]]; then
fn-archive-log-security-event "rejected_extracted_symlink" "post_extraction" "$link" "target=${target} dir=${CANONICAL_DIR}"
dokku_log_fail "Archive contains symlinks pointing outside extraction directory"
fi
done <<<"$SYMLINKS"
}
fn-archive-tar-supports-no-unsafe-links() {
declare desc="returns 0 if the tar binary supports --no-unsafe-links"
if tar --help 2>&1 | grep -q -- "--no-unsafe-links"; then
return 0
fi
return 1
}
fn-archive-extract-tar() {
declare desc="safely extracts a tar archive with symlink protection"
declare ARCHIVE_PATH="$1" DEST_DIR="$2" ARCHIVE_TYPE="$3" STRIP_COMPONENTS="${4:-0}"
local TAR_FLAG
TAR_FLAG="$(fn-archive-tar-flag "$ARCHIVE_TYPE")" || dokku_log_fail "Unsupported archive type for extraction: ${ARCHIVE_TYPE}"
fn-archive-check-bomb-protection "$ARCHIVE_PATH" "$ARCHIVE_TYPE"
fn-archive-validate-tar-entries "$ARCHIVE_PATH" "$ARCHIVE_TYPE"
fn-archive-log-security-event "extraction_started" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "dest=${DEST_DIR}"
local TAR_ARGS=()
if fn-archive-tar-supports-no-unsafe-links; then
TAR_ARGS+=("--no-unsafe-links")
fi
if [[ "$STRIP_COMPONENTS" -gt 0 ]]; then
TAR_ARGS+=("--strip-components=$STRIP_COMPONENTS")
fi
if [[ -n "$TAR_FLAG" ]] && [[ "${#TAR_ARGS[@]}" -gt 0 ]]; then
tar -x "$TAR_FLAG" -C "$DEST_DIR" -f "$ARCHIVE_PATH" "${TAR_ARGS[@]}"
elif [[ -n "$TAR_FLAG" ]]; then
tar -x "$TAR_FLAG" -C "$DEST_DIR" -f "$ARCHIVE_PATH"
elif [[ "${#TAR_ARGS[@]}" -gt 0 ]]; then
tar -x -C "$DEST_DIR" -f "$ARCHIVE_PATH" "${TAR_ARGS[@]}"
else
tar -x -C "$DEST_DIR" -f "$ARCHIVE_PATH"
fi
fn-archive-validate-extracted-symlinks "$DEST_DIR"
fn-archive-log-security-event "extraction_completed" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "dest=${DEST_DIR}"
}
fn-archive-extract-tar-stdin() {
declare desc="safely extracts a tar archive from stdin into the current directory"
declare DEST_DIR="$1" SOURCE_LABEL="${2:-stdin}"
local STDIN_TMP
STDIN_TMP="$(mktemp "/tmp/dokku-${DOKKU_PID}-archive.XXXXXX")"
trap "rm -f '$STDIN_TMP' >/dev/null 2>&1 || true" RETURN
cat <&0 >"$STDIN_TMP"
fn-archive-extract-tar "$STDIN_TMP" "$DEST_DIR" "tar" 0
rm -f "$STDIN_TMP" >/dev/null 2>&1 || true
}
fn-archive-extract-zip() {
declare desc="safely extracts a zip archive with post-extraction symlink validation"
declare ARCHIVE_PATH="$1" DEST_DIR="$2"
local MAX_SIZE MAX_FILES
MAX_SIZE="$(fn-archive-max-size)"
MAX_FILES="$(fn-archive-max-files)"
local ARCHIVE_SIZE
ARCHIVE_SIZE="$(stat -c %s "$ARCHIVE_PATH" 2>/dev/null || stat -f %z "$ARCHIVE_PATH" 2>/dev/null || echo 0)"
if [[ "$ARCHIVE_SIZE" -gt "$MAX_SIZE" ]]; then
fn-archive-log-security-event "rejected_archive_too_large" "zip" "$ARCHIVE_PATH" "size=${ARCHIVE_SIZE} max=${MAX_SIZE}"
dokku_log_fail "Archive exceeds maximum allowed size of ${MAX_SIZE} bytes"
fi
local FILE_COUNT
FILE_COUNT="$(unzip -l "$ARCHIVE_PATH" 2>/dev/null | tail -n 1 | awk '{print $2}')"
if [[ -n "$FILE_COUNT" ]] && [[ "$FILE_COUNT" -gt "$MAX_FILES" ]]; then
fn-archive-log-security-event "rejected_archive_too_many_files" "zip" "$ARCHIVE_PATH" "count=${FILE_COUNT} max=${MAX_FILES}"
dokku_log_fail "Archive contains ${FILE_COUNT} entries which exceeds the maximum of ${MAX_FILES}"
fi
if unzip -l "$ARCHIVE_PATH" 2>/dev/null | awk 'NR>3 {print $NF}' | grep -qE '(^/|(^|/)\.\.(/|$))'; then
fn-archive-log-security-event "rejected_unsafe_zip_path" "zip" "$ARCHIVE_PATH" "absolute or traversal path detected"
dokku_log_fail "Archive contains entries with unsafe paths"
fi
fn-archive-log-security-event "extraction_started" "zip" "$ARCHIVE_PATH" "dest=${DEST_DIR}"
unzip -d "$DEST_DIR" "$ARCHIVE_PATH"
fn-archive-validate-extracted-symlinks "$DEST_DIR"
fn-archive-log-security-event "extraction_completed" "zip" "$ARCHIVE_PATH" "dest=${DEST_DIR}"
}

View File

@@ -2,6 +2,10 @@
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
if [[ -f "${PLUGIN_CORE_AVAILABLE_PATH:-/var/lib/dokku/core-plugins/available}/common/archive-functions" ]]; then
source "${PLUGIN_CORE_AVAILABLE_PATH:-/var/lib/dokku/core-plugins/available}/common/archive-functions"
fi
has_tty() {
declare desc="return 0 if we have a tty"
if [[ "$DOKKU_FORCE_TTY" == "true" ]]; then

View File

@@ -26,13 +26,13 @@ trigger-git-git-from-archive() {
local COMMON_PREFIX=$(tar -tf "$TMP_WORK_DIR_2/src.tar" | sed -e 'N;s/^\(.*\).*\n\1.*$/\1\n\1/;D')
local BOGUS_PARTS=$(echo "$COMMON_PREFIX " | awk 'BEGIN{FS="/"} {print NF-1}')
dokku_log_verbose "Striping $BOGUS_PARTS worth of directories from tarball"
tar -x -C "$TMP_WORK_DIR_3" -f "$TMP_WORK_DIR_2/src.tar" --strip-components="$BOGUS_PARTS"
fn-archive-extract-tar "$TMP_WORK_DIR_2/src.tar" "$TMP_WORK_DIR_3" "tar" "$BOGUS_PARTS"
elif [[ "$ARCHIVE_TYPE" == "tar.gz" ]]; then
dokku_log_verbose "Extracting gzipped tarball"
tar -x -C "$TMP_WORK_DIR_3" -f "$TMP_WORK_DIR_2/src.tar.gz" -z
fn-archive-extract-tar "$TMP_WORK_DIR_2/src.tar.gz" "$TMP_WORK_DIR_3" "tar.gz" 0
elif [[ "$ARCHIVE_TYPE" == "zip" ]]; then
dokku_log_verbose "Extracting zipball"
unzip -d "$TMP_WORK_DIR_3" "$TMP_WORK_DIR_2/src.zip"
fn-archive-extract-zip "$TMP_WORK_DIR_2/src.zip" "$TMP_WORK_DIR_3"
fi
chmod -R u+r "$TMP_WORK_DIR_3"

View File

@@ -369,12 +369,16 @@ cmd-git-report-single() {
local flag_map=()
if [[ "$APP" == "--global" ]]; then
flag_map=(
"--git-global-archive-max-files: $(fn-plugin-property-get "git" "--global" "archive-max-files" "10000")"
"--git-global-archive-max-size: $(fn-plugin-property-get "git" "--global" "archive-max-size" "1073741824")"
"--git-global-deploy-branch: $(fn-plugin-property-get "git" "--global" "deploy-branch" "master")"
)
else
verify_app_name "$APP"
flag_map=(
"--git-deploy-branch: $(fn-plugin-property-get "git" "$APP" "deploy-branch" "master")"
"--git-global-archive-max-files: $(fn-plugin-property-get "git" "--global" "archive-max-files" "10000")"
"--git-global-archive-max-size: $(fn-plugin-property-get "git" "--global" "archive-max-size" "1073741824")"
"--git-global-deploy-branch: $(fn-plugin-property-get "git" "--global" "deploy-branch" "master")"
"--git-keep-git-dir: $(fn-plugin-property-get "git" "$APP" "keep-git-dir" "false")"
"--git-rev-env-var: $(fn-plugin-property-get "git" "$APP" "rev-env-var" "GIT_REV")"

View File

@@ -9,13 +9,13 @@ cmd-git-set() {
declare cmd="git:set"
[[ "$1" == "$cmd" ]] && shift 1
declare APP="$1" KEY="$2" VALUE="$3"
local VALID_KEYS=("deploy-branch" "keep-git-dir" "rev-env-var" "source-image")
local VALID_KEYS=("archive-max-files" "archive-max-size" "deploy-branch" "keep-git-dir" "rev-env-var" "source-image")
[[ "$APP" == "--global" ]] || verify_app_name "$APP"
[[ -z "$KEY" ]] && dokku_log_fail "No key specified"
if ! fn-in-array "$KEY" "${VALID_KEYS[@]}"; then
dokku_log_fail "Invalid key specified, valid keys include: deploy-branch, keep-git-dir, rev-env-var, source-image"
dokku_log_fail "Invalid key specified, valid keys include: archive-max-files, archive-max-size, deploy-branch, keep-git-dir, rev-env-var, source-image"
fi
if [[ -n "$VALUE" ]]; then