Merge pull request #8591 from dokku/arbitrary-file-write
Harden archive extraction against symlink traversal
This commit is contained in:
249
tests/unit/archive-security.bats
Normal file
249
tests/unit/archive-security.bats
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user