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

@@ -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
}