fix: prevent tar symlink traversal in archive extraction

Archives passed to git:from-archive and certs:add were extracted without symlink or path validation, allowing a crafted archive to write arbitrary files anywhere writable by the dokku user via symlink traversal. Extraction now pre-scans entries for absolute paths, parent traversal, and unsafe symlinks, applies the GNU tar `--no-unsafe-links` flag when available, and validates symlinks after extraction.
This commit is contained in:
Jose Diaz-Gonzalez
2026-05-09 12:54:42 -04:00
parent 2974830e8f
commit 2df0791fcd
7 changed files with 394 additions and 4 deletions

View File

@@ -41,6 +41,9 @@ dokku certs:add node-js-app < cert-key.tar
cat yourdomain_com.crt yourdomain_com.ca-bundle > server.crt
```
> [!NOTE]
> Archives passed to `certs:add` are validated before extraction to prevent path traversal and symlink escape attacks. Archives containing absolute paths, parent directory traversal entries, or symlinks pointing outside the extraction directory will be rejected.
#### SSL and Multiple Domains
When an SSL certificate is associated to an application, the certificate will be associated with _all_ domains currently associated with said application. Your certificate _should_ be associated with all of those domains, otherwise accessing the application will result in SSL errors. If you wish to remove one of the domains from the application, refer to the [domain configuration documentation](/docs/configuration/domains.md).

View File

@@ -32,3 +32,12 @@ Finally, if the archive url is specified as `--`, the archive will be fetched fr
```shell
curl -sSL https://github.com/dokku/smoke-test-app/releases/download/2.0.0/smoke-test-app.tar | dokku git:from-archive node-js-app --
```
## Archive Safety
Archive contents are validated before extraction to prevent path traversal and symlink escape attacks. Archives containing absolute paths, parent directory traversal entries (`..`), or symlinks pointing outside the extraction directory are rejected.
The following limits can be configured via environment variables:
- `DOKKU_ARCHIVE_MAX_SIZE` - maximum archive size in bytes (default: `1073741824`, 1 GiB)
- `DOKKU_ARCHIVE_MAX_FILES` - maximum number of entries in an archive (default: `10000`)

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 '^')

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

@@ -0,0 +1,196 @@
#!/usr/bin/env bash
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
DOKKU_ARCHIVE_MAX_SIZE="${DOKKU_ARCHIVE_MAX_SIZE:-1073741824}"
DOKKU_ARCHIVE_MAX_FILES="${DOKKU_ARCHIVE_MAX_FILES:-10000}"
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 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 "$DOKKU_ARCHIVE_MAX_SIZE" ]]; then
fn-archive-log-security-event "rejected_archive_too_large" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "size=${ARCHIVE_SIZE} max=${DOKKU_ARCHIVE_MAX_SIZE}"
dokku_log_fail "Archive exceeds maximum allowed size of ${DOKKU_ARCHIVE_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 "$DOKKU_ARCHIVE_MAX_FILES" ]]; then
fn-archive-log-security-event "rejected_archive_too_many_files" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "count=${FILE_COUNT} max=${DOKKU_ARCHIVE_MAX_FILES}"
dokku_log_fail "Archive contains ${FILE_COUNT} entries which exceeds the maximum of ${DOKKU_ARCHIVE_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 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 "$DOKKU_ARCHIVE_MAX_SIZE" ]]; then
fn-archive-log-security-event "rejected_archive_too_large" "zip" "$ARCHIVE_PATH" "size=${ARCHIVE_SIZE} max=${DOKKU_ARCHIVE_MAX_SIZE}"
dokku_log_fail "Archive exceeds maximum allowed size of ${DOKKU_ARCHIVE_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 "$DOKKU_ARCHIVE_MAX_FILES" ]]; then
fn-archive-log-security-event "rejected_archive_too_many_files" "zip" "$ARCHIVE_PATH" "count=${FILE_COUNT} max=${DOKKU_ARCHIVE_MAX_FILES}"
dokku_log_fail "Archive contains ${FILE_COUNT} entries which exceeds the maximum of ${DOKKU_ARCHIVE_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

@@ -0,0 +1,178 @@
#!/usr/bin/env bats
load test_helper
ARCHIVE_TMP_DIR="${BATS_TMPDIR}/archive-security"
setup() {
global_setup
create_app
mkdir -p "$ARCHIVE_TMP_DIR"
}
teardown() {
rm -rf "$ARCHIVE_TMP_DIR"
rm -f /tmp/dokku-archive-security-canary.txt
destroy_app
global_teardown
}
create_absolute_symlink_tar() {
local OUTPUT="$1" FORMAT="${2:-tar}"
python3 - "$OUTPUT" "$FORMAT" <<'PY'
import io, sys, tarfile
output, fmt = sys.argv[1], sys.argv[2]
mode = "w:gz" if fmt == "tar.gz" else "w"
with tarfile.open(output, mode) as t:
link = tarfile.TarInfo("pwn")
link.type = tarfile.SYMTYPE
link.linkname = "/tmp"
t.addfile(link)
payload = b"canary content\n"
fi = tarfile.TarInfo("pwn/dokku-archive-security-canary.txt")
fi.size = len(payload)
t.addfile(fi, io.BytesIO(payload))
readme = b"# dummy\n"
ri = tarfile.TarInfo("README.md")
ri.size = len(readme)
t.addfile(ri, io.BytesIO(readme))
PY
}
create_relative_traversal_symlink_tar() {
local OUTPUT="$1" FORMAT="${2:-tar}"
python3 - "$OUTPUT" "$FORMAT" <<'PY'
import io, sys, tarfile
output, fmt = sys.argv[1], sys.argv[2]
mode = "w:gz" if fmt == "tar.gz" else "w"
with tarfile.open(output, mode) as t:
link = tarfile.TarInfo("pwn")
link.type = tarfile.SYMTYPE
link.linkname = "../../../../tmp"
t.addfile(link)
payload = b"canary content\n"
fi = tarfile.TarInfo("pwn/dokku-archive-security-canary.txt")
fi.size = len(payload)
t.addfile(fi, io.BytesIO(payload))
PY
}
create_absolute_path_tar() {
local OUTPUT="$1" FORMAT="${2:-tar}"
python3 - "$OUTPUT" "$FORMAT" <<'PY'
import io, sys, tarfile
output, fmt = sys.argv[1], sys.argv[2]
mode = "w:gz" if fmt == "tar.gz" else "w"
with tarfile.open(output, mode) as t:
payload = b"absolute path payload\n"
fi = tarfile.TarInfo("/tmp/dokku-archive-security-canary.txt")
fi.size = len(payload)
t.addfile(fi, io.BytesIO(payload))
PY
}
create_traversal_path_tar() {
local OUTPUT="$1" FORMAT="${2:-tar}"
python3 - "$OUTPUT" "$FORMAT" <<'PY'
import io, sys, tarfile
output, fmt = sys.argv[1], sys.argv[2]
mode = "w:gz" if fmt == "tar.gz" else "w"
with tarfile.open(output, mode) as t:
payload = b"traversal payload\n"
fi = tarfile.TarInfo("../../../tmp/dokku-archive-security-canary.txt")
fi.size = len(payload)
t.addfile(fi, io.BytesIO(payload))
PY
}
create_evil_certs_tar() {
local OUTPUT="$1"
python3 - "$OUTPUT" <<'PY'
import io, sys, tarfile
output = sys.argv[1]
with tarfile.open(output, "w") as t:
link = tarfile.TarInfo("pwn")
link.type = tarfile.SYMTYPE
link.linkname = "/tmp"
t.addfile(link)
payload = b"canary content\n"
fi = tarfile.TarInfo("pwn/dokku-archive-security-canary.txt")
fi.size = len(payload)
t.addfile(fi, io.BytesIO(payload))
crt = b"-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n"
key = b"-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n"
tcrt = tarfile.TarInfo("server.crt")
tcrt.size = len(crt)
t.addfile(tcrt, io.BytesIO(crt))
tkey = tarfile.TarInfo("server.key")
tkey.size = len(key)
t.addfile(tkey, io.BytesIO(key))
PY
}
@test "(archive-security) git:from-archive rejects tar with absolute symlink target" {
create_absolute_symlink_tar "$ARCHIVE_TMP_DIR/evil.tar" "tar"
run /bin/bash -c "cat $ARCHIVE_TMP_DIR/evil.tar | dokku git:from-archive $TEST_APP --"
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "absolute targets"
[[ ! -f /tmp/dokku-archive-security-canary.txt ]]
}
@test "(archive-security) git:from-archive rejects tar.gz with absolute symlink target" {
create_absolute_symlink_tar "$ARCHIVE_TMP_DIR/evil.tar.gz" "tar.gz"
run /bin/bash -c "cat $ARCHIVE_TMP_DIR/evil.tar.gz | dokku git:from-archive --archive-type tar.gz $TEST_APP --"
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "absolute targets"
[[ ! -f /tmp/dokku-archive-security-canary.txt ]]
}
@test "(archive-security) git:from-archive rejects tar with relative traversal symlink" {
create_relative_traversal_symlink_tar "$ARCHIVE_TMP_DIR/evil.tar" "tar"
run /bin/bash -c "cat $ARCHIVE_TMP_DIR/evil.tar | dokku git:from-archive $TEST_APP --"
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "symlinks pointing outside extraction directory"
[[ ! -f /tmp/dokku-archive-security-canary.txt ]]
}
@test "(archive-security) git:from-archive rejects tar with absolute paths" {
create_absolute_path_tar "$ARCHIVE_TMP_DIR/evil.tar" "tar"
run /bin/bash -c "cat $ARCHIVE_TMP_DIR/evil.tar | dokku git:from-archive $TEST_APP --"
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "absolute paths"
[[ ! -f /tmp/dokku-archive-security-canary.txt ]]
}
@test "(archive-security) git:from-archive rejects tar with parent traversal" {
create_traversal_path_tar "$ARCHIVE_TMP_DIR/evil.tar" "tar"
run /bin/bash -c "cat $ARCHIVE_TMP_DIR/evil.tar | dokku git:from-archive $TEST_APP --"
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "Archive contains entries with parent directory traversal"
[[ ! -f /tmp/dokku-archive-security-canary.txt ]]
}
@test "(archive-security) certs:add rejects tar with absolute symlink target" {
create_evil_certs_tar "$ARCHIVE_TMP_DIR/evil-certs.tar"
run /bin/bash -c "cat $ARCHIVE_TMP_DIR/evil-certs.tar | dokku certs:add $TEST_APP"
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "absolute targets"
[[ ! -f /tmp/dokku-archive-security-canary.txt ]]
}
@test "(archive-security) certs:add still works with legitimate tarball" {
run /bin/bash -c "dokku certs:add $TEST_APP < $BATS_TEST_DIRNAME/server_ssl.tar"
echo "output: $output"
echo "status: $status"
assert_success
}