diff --git a/docs/deployment/methods/archive.md b/docs/deployment/methods/archive.md index a8c279821..52196c5a5 100644 --- a/docs/deployment/methods/archive.md +++ b/docs/deployment/methods/archive.md @@ -37,7 +37,24 @@ curl -sSL https://github.com/dokku/smoke-test-app/releases/download/2.0.0/smoke- 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: +The following limits can be configured via global git properties: -- `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`) +- `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 +``` diff --git a/plugins/common/archive-functions b/plugins/common/archive-functions index 5653ae099..9ed12ccc9 100755 --- a/plugins/common/archive-functions +++ b/plugins/common/archive-functions @@ -2,8 +2,24 @@ 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}" +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" @@ -26,12 +42,16 @@ fn-archive-tar-flag() { 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 "$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" + 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 @@ -44,9 +64,9 @@ fn-archive-check-bomb-protection() { 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}" + 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 } @@ -169,19 +189,22 @@ fn-archive-extract-tar-stdin() { 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 "$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" + 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 "$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}" + 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 diff --git a/plugins/git/internal-functions b/plugins/git/internal-functions index 7434f36b0..858b82a9d 100755 --- a/plugins/git/internal-functions +++ b/plugins/git/internal-functions @@ -360,12 +360,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")" diff --git a/plugins/git/subcommands/set b/plugins/git/subcommands/set index 50bb3d180..f7b023091 100755 --- a/plugins/git/subcommands/set +++ b/plugins/git/subcommands/set @@ -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 diff --git a/tests/unit/archive-security.bats b/tests/unit/archive-security.bats index e47ae92d0..be4607922 100644 --- a/tests/unit/archive-security.bats +++ b/tests/unit/archive-security.bats @@ -13,6 +13,8 @@ setup() { 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 } @@ -85,6 +87,20 @@ with tarfile.open(output, mode) as t: 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' @@ -176,3 +192,58 @@ PY 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 +}