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

@@ -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,29 @@ 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 global git properties:
- `archive-max-size` - maximum archive size in bytes (default: `1073741824`, 1 GiB)
- `archive-max-files` - maximum number of entries in an archive (default: `10000`)
```shell
dokku git:set --global archive-max-size 2147483648
dokku git:set --global archive-max-files 20000
```
Unset a property to restore the default:
```shell
dokku git:set --global archive-max-size
```
The current values can be inspected via the global git report:
```shell
dokku git:report --global
```

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

View File

@@ -0,0 +1,249 @@
#!/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
dokku git:set --global archive-max-size "" >/dev/null 2>&1 || true
dokku git:set --global archive-max-files "" >/dev/null 2>&1 || true
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_safe_multi_file_tar() {
local OUTPUT="$1"
python3 - "$OUTPUT" <<'PY'
import io, sys, tarfile
output = sys.argv[1]
with tarfile.open(output, "w") as t:
for name in ("README.md", "Procfile", "app.py", "requirements.txt"):
payload = (name + " contents\n").encode()
ti = tarfile.TarInfo(name)
ti.size = len(payload)
t.addfile(ti, 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
}
@test "(archive-security) git:from-archive enforces archive-max-size property" {
create_safe_multi_file_tar "$ARCHIVE_TMP_DIR/safe.tar"
run /bin/bash -c "dokku git:set --global archive-max-size 1"
assert_success
run /bin/bash -c "cat $ARCHIVE_TMP_DIR/safe.tar | dokku git:from-archive $TEST_APP --"
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "Archive exceeds maximum allowed size of 1 bytes"
run /bin/bash -c "dokku git:set --global archive-max-size"
assert_success
}
@test "(archive-security) git:from-archive enforces archive-max-files property" {
create_safe_multi_file_tar "$ARCHIVE_TMP_DIR/safe.tar"
run /bin/bash -c "dokku git:set --global archive-max-files 1"
assert_success
run /bin/bash -c "cat $ARCHIVE_TMP_DIR/safe.tar | dokku git:from-archive $TEST_APP --"
echo "output: $output"
echo "status: $status"
assert_failure
assert_output_contains "exceeds the maximum of 1"
run /bin/bash -c "dokku git:set --global archive-max-files"
assert_success
}
@test "(archive-security) git:report exposes archive-max-size and archive-max-files" {
run /bin/bash -c "dokku git:report --global --git-global-archive-max-size"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "1073741824"
run /bin/bash -c "dokku git:report --global --git-global-archive-max-files"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "10000"
run /bin/bash -c "dokku git:set --global archive-max-size 2147483648"
assert_success
run /bin/bash -c "dokku git:report --global --git-global-archive-max-size"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "2147483648"
run /bin/bash -c "dokku git:set --global archive-max-size"
assert_success
}