feat: configure archive limits via git properties
Replaces the `DOKKU_ARCHIVE_MAX_SIZE` and `DOKKU_ARCHIVE_MAX_FILES` environment variables with global git properties (`archive-max-size` and `archive-max-files`), configurable via `dokku git:set --global` and surfaced through `dokku git:report --global`. Defaults remain `1073741824` bytes and `10000` entries.
This commit is contained in:
@@ -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.
|
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)
|
- `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-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
|
||||||
|
```
|
||||||
|
|||||||
@@ -2,8 +2,24 @@
|
|||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
[[ $DOKKU_TRACE ]] && set -x
|
[[ $DOKKU_TRACE ]] && set -x
|
||||||
|
|
||||||
DOKKU_ARCHIVE_MAX_SIZE="${DOKKU_ARCHIVE_MAX_SIZE:-1073741824}"
|
if ! declare -F fn-plugin-property-get >/dev/null 2>&1; then
|
||||||
DOKKU_ARCHIVE_MAX_FILES="${DOKKU_ARCHIVE_MAX_FILES:-10000}"
|
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() {
|
fn-archive-log-security-event() {
|
||||||
declare desc="logs an archive security event for auditing"
|
declare desc="logs an archive security event for auditing"
|
||||||
@@ -26,12 +42,16 @@ fn-archive-tar-flag() {
|
|||||||
fn-archive-check-bomb-protection() {
|
fn-archive-check-bomb-protection() {
|
||||||
declare desc="validates archive against bomb protection limits"
|
declare desc="validates archive against bomb protection limits"
|
||||||
declare ARCHIVE_PATH="$1" ARCHIVE_TYPE="$2"
|
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
|
local ARCHIVE_SIZE
|
||||||
ARCHIVE_SIZE="$(stat -c %s "$ARCHIVE_PATH" 2>/dev/null || stat -f %z "$ARCHIVE_PATH" 2>/dev/null || echo 0)"
|
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
|
if [[ "$ARCHIVE_SIZE" -gt "$MAX_SIZE" ]]; then
|
||||||
fn-archive-log-security-event "rejected_archive_too_large" "$ARCHIVE_TYPE" "$ARCHIVE_PATH" "size=${ARCHIVE_SIZE} max=${DOKKU_ARCHIVE_MAX_SIZE}"
|
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 ${DOKKU_ARCHIVE_MAX_SIZE} bytes"
|
dokku_log_fail "Archive exceeds maximum allowed size of ${MAX_SIZE} bytes"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local TAR_FLAG
|
local TAR_FLAG
|
||||||
@@ -44,9 +64,9 @@ fn-archive-check-bomb-protection() {
|
|||||||
FILE_COUNT="$(tar -tf "$ARCHIVE_PATH" 2>/dev/null | wc -l)"
|
FILE_COUNT="$(tar -tf "$ARCHIVE_PATH" 2>/dev/null | wc -l)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$FILE_COUNT" -gt "$DOKKU_ARCHIVE_MAX_FILES" ]]; then
|
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=${DOKKU_ARCHIVE_MAX_FILES}"
|
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 ${DOKKU_ARCHIVE_MAX_FILES}"
|
dokku_log_fail "Archive contains ${FILE_COUNT} entries which exceeds the maximum of ${MAX_FILES}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,19 +189,22 @@ fn-archive-extract-tar-stdin() {
|
|||||||
fn-archive-extract-zip() {
|
fn-archive-extract-zip() {
|
||||||
declare desc="safely extracts a zip archive with post-extraction symlink validation"
|
declare desc="safely extracts a zip archive with post-extraction symlink validation"
|
||||||
declare ARCHIVE_PATH="$1" DEST_DIR="$2"
|
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
|
local ARCHIVE_SIZE
|
||||||
ARCHIVE_SIZE="$(stat -c %s "$ARCHIVE_PATH" 2>/dev/null || stat -f %z "$ARCHIVE_PATH" 2>/dev/null || echo 0)"
|
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
|
if [[ "$ARCHIVE_SIZE" -gt "$MAX_SIZE" ]]; then
|
||||||
fn-archive-log-security-event "rejected_archive_too_large" "zip" "$ARCHIVE_PATH" "size=${ARCHIVE_SIZE} max=${DOKKU_ARCHIVE_MAX_SIZE}"
|
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 ${DOKKU_ARCHIVE_MAX_SIZE} bytes"
|
dokku_log_fail "Archive exceeds maximum allowed size of ${MAX_SIZE} bytes"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local FILE_COUNT
|
local FILE_COUNT
|
||||||
FILE_COUNT="$(unzip -l "$ARCHIVE_PATH" 2>/dev/null | tail -n 1 | awk '{print $2}')"
|
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
|
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=${DOKKU_ARCHIVE_MAX_FILES}"
|
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 ${DOKKU_ARCHIVE_MAX_FILES}"
|
dokku_log_fail "Archive contains ${FILE_COUNT} entries which exceeds the maximum of ${MAX_FILES}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if unzip -l "$ARCHIVE_PATH" 2>/dev/null | awk 'NR>3 {print $NF}' | grep -qE '(^/|(^|/)\.\.(/|$))'; then
|
if unzip -l "$ARCHIVE_PATH" 2>/dev/null | awk 'NR>3 {print $NF}' | grep -qE '(^/|(^|/)\.\.(/|$))'; then
|
||||||
|
|||||||
@@ -360,12 +360,16 @@ cmd-git-report-single() {
|
|||||||
local flag_map=()
|
local flag_map=()
|
||||||
if [[ "$APP" == "--global" ]]; then
|
if [[ "$APP" == "--global" ]]; then
|
||||||
flag_map=(
|
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")"
|
"--git-global-deploy-branch: $(fn-plugin-property-get "git" "--global" "deploy-branch" "master")"
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
verify_app_name "$APP"
|
verify_app_name "$APP"
|
||||||
flag_map=(
|
flag_map=(
|
||||||
"--git-deploy-branch: $(fn-plugin-property-get "git" "$APP" "deploy-branch" "master")"
|
"--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-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-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")"
|
"--git-rev-env-var: $(fn-plugin-property-get "git" "$APP" "rev-env-var" "GIT_REV")"
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ cmd-git-set() {
|
|||||||
declare cmd="git:set"
|
declare cmd="git:set"
|
||||||
[[ "$1" == "$cmd" ]] && shift 1
|
[[ "$1" == "$cmd" ]] && shift 1
|
||||||
declare APP="$1" KEY="$2" VALUE="$3"
|
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"
|
[[ "$APP" == "--global" ]] || verify_app_name "$APP"
|
||||||
|
|
||||||
[[ -z "$KEY" ]] && dokku_log_fail "No key specified"
|
[[ -z "$KEY" ]] && dokku_log_fail "No key specified"
|
||||||
|
|
||||||
if ! fn-in-array "$KEY" "${VALID_KEYS[@]}"; then
|
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
|
fi
|
||||||
|
|
||||||
if [[ -n "$VALUE" ]]; then
|
if [[ -n "$VALUE" ]]; then
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ setup() {
|
|||||||
teardown() {
|
teardown() {
|
||||||
rm -rf "$ARCHIVE_TMP_DIR"
|
rm -rf "$ARCHIVE_TMP_DIR"
|
||||||
rm -f /tmp/dokku-archive-security-canary.txt
|
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
|
destroy_app
|
||||||
global_teardown
|
global_teardown
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,20 @@ with tarfile.open(output, mode) as t:
|
|||||||
PY
|
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() {
|
create_evil_certs_tar() {
|
||||||
local OUTPUT="$1"
|
local OUTPUT="$1"
|
||||||
python3 - "$OUTPUT" <<'PY'
|
python3 - "$OUTPUT" <<'PY'
|
||||||
@@ -176,3 +192,58 @@ PY
|
|||||||
echo "status: $status"
|
echo "status: $status"
|
||||||
assert_success
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user