308 lines
11 KiB
Shell
Executable file
308 lines
11 KiB
Shell
Executable file
#!/usr/bin/env bash
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
# Copyright (c) 2025-2026 Cristian Cezar Moisés
|
|
# Extraction confinement and atomic-output regression tests.
|
|
|
|
set -Eeuo pipefail
|
|
|
|
REPO_ROOT=$(pwd -P)
|
|
ZUPT_BIN=${1:-$REPO_ROOT/zupt}
|
|
case $ZUPT_BIN in
|
|
/*) ;;
|
|
*) ZUPT_BIN=$REPO_ROOT/${ZUPT_BIN#./} ;;
|
|
esac
|
|
TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/zupt-path-traversal.XXXXXX")
|
|
cleanup() {
|
|
local status=$?
|
|
chmod -R u+rwX "$TEST_ROOT" 2>/dev/null || true
|
|
rm -rf -- "$TEST_ROOT"
|
|
exit "$status"
|
|
}
|
|
trap cleanup EXIT
|
|
trap 'exit 130' HUP INT TERM
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
SKIP=0
|
|
pass() { printf ' PASS: %s\n' "$1"; PASS=$((PASS + 1)); }
|
|
fail() { printf ' FAIL: %s\n' "$1"; FAIL=$((FAIL + 1)); }
|
|
skip() { printf ' SKIP: %s\n' "$1"; SKIP=$((SKIP + 1)); }
|
|
WINDOWS_NATIVE=0
|
|
case "$(uname -s)" in
|
|
MINGW*|MSYS*|CYGWIN*) WINDOWS_NATIVE=1 ;;
|
|
esac
|
|
|
|
FIXTURE=$TEST_ROOT/archive-path-fixture
|
|
# Compiler and linker flag variables intentionally expand into argument lists,
|
|
# matching the Makefile command-line contract.
|
|
# shellcheck disable=SC2086
|
|
"${CC:-cc}" ${CPPFLAGS:-} ${CFLAGS:-} -std=c11 -I"$REPO_ROOT/include" \
|
|
"$REPO_ROOT/tests/archive_path_fixture.c" "$REPO_ROOT/src/zupt_xxh.c" \
|
|
${LDFLAGS:-} ${LDLIBS:-} -o "$FIXTURE"
|
|
|
|
make_fixture() {
|
|
MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$1" "--entry=$2"
|
|
# Prove that the archive passed header, trailer, index-block, index checksum,
|
|
# decompression, and index parsing before using it as a negative fixture.
|
|
"$ZUPT_BIN" list "$1" > "$TEST_ROOT/list.log" 2>&1
|
|
grep -F -- "$2" "$TEST_ROOT/list.log" >/dev/null
|
|
}
|
|
|
|
expect_unsafe_path() {
|
|
local label=$1 archive=$2 entry=$3 output=$4 log=$TEST_ROOT/extract.log rc
|
|
make_fixture "$archive" "$entry"
|
|
mkdir -p "$output"
|
|
set +e
|
|
"$ZUPT_BIN" extract -o "$output" "$archive" > "$log" 2>&1
|
|
rc=$?
|
|
set -e
|
|
if ((rc != 0)) && grep -F 'rejected unsafe path' "$log" >/dev/null; then
|
|
pass "$label"
|
|
else
|
|
fail "$label"
|
|
fi
|
|
}
|
|
|
|
cd "$TEST_ROOT"
|
|
|
|
expect_unsafe_path 'relative .. entry is rejected after a valid index parse' \
|
|
"$TEST_ROOT/relative.zupt" '../escaped.txt' "$TEST_ROOT/relative-out"
|
|
[[ ! -e $TEST_ROOT/escaped.txt ]] || fail 'relative traversal wrote outside root'
|
|
|
|
ABSOLUTE_TARGET=$TEST_ROOT/absolute-owned
|
|
expect_unsafe_path 'absolute entry is rejected after a valid index parse' \
|
|
"$TEST_ROOT/absolute.zupt" "$ABSOLUTE_TARGET" "$TEST_ROOT/absolute-out"
|
|
if [[ ! -e $ABSOLUTE_TARGET ]]; then
|
|
pass 'absolute target remains absent'
|
|
else
|
|
fail 'absolute target remains absent'
|
|
fi
|
|
|
|
for case_data in \
|
|
'trailing-space|dir/.. ' \
|
|
'alternate-data-stream|dir/name:stream' \
|
|
'reserved-device|dir/CON' \
|
|
'reserved-device-extension|dir/LPT1.txt' \
|
|
'trailing-dot|dir/file.'; do
|
|
label=${case_data%%|*}
|
|
entry=${case_data#*|}
|
|
expect_unsafe_path "Windows-normalized $label path is rejected" \
|
|
"$TEST_ROOT/$label.zupt" "$entry" "$TEST_ROOT/$label-out"
|
|
done
|
|
|
|
control_entry=$'safe\033[31mRED\033[0m.txt'
|
|
MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$TEST_ROOT/control.zupt" \
|
|
"--entry=$control_entry"
|
|
set +e
|
|
"$ZUPT_BIN" list "$TEST_ROOT/control.zupt" \
|
|
> "$TEST_ROOT/control.log" 2>&1
|
|
control_status=$?
|
|
set -e
|
|
if ((control_status != 0)) && ! grep -q $'\033' "$TEST_ROOT/control.log"; then
|
|
pass 'control-byte archive path is rejected without terminal injection'
|
|
else
|
|
fail 'control-byte archive path is rejected without terminal injection'
|
|
fi
|
|
|
|
file_contains_hex_bytes() {
|
|
python3 - "$1" "$2" <<'PY'
|
|
import pathlib
|
|
import sys
|
|
|
|
data = pathlib.Path(sys.argv[1]).read_bytes()
|
|
needle = bytes.fromhex(sys.argv[2])
|
|
raise SystemExit(0 if needle in data else 1)
|
|
PY
|
|
}
|
|
|
|
expect_display_unsafe_hex_path_rejected() {
|
|
local label=$1 name=$2 entry_hex=$3 forbidden_hex=$4
|
|
local archive=$TEST_ROOT/$name.zupt log=$TEST_ROOT/$name.log status
|
|
MSYS2_ARG_CONV_EXCL='--entry-hex=' \
|
|
"$FIXTURE" "$archive" "--entry-hex=$entry_hex"
|
|
if ! file_contains_hex_bytes "$archive" "$entry_hex"; then
|
|
printf ' fixture did not preserve the requested path bytes: %s\n' \
|
|
"$entry_hex" >&2
|
|
fail "$label"
|
|
return
|
|
fi
|
|
set +e
|
|
"$ZUPT_BIN" list "$archive" > "$log" 2>&1
|
|
status=$?
|
|
set -e
|
|
if ((status != 0)) && ! file_contains_hex_bytes "$log" "$forbidden_hex"; then
|
|
pass "$label"
|
|
else
|
|
fail "$label"
|
|
fi
|
|
}
|
|
|
|
if MSYS2_ARG_CONV_EXCL='--entry-hex=' \
|
|
"$FIXTURE" "$TEST_ROOT/invalid-hex.zupt" '--entry-hex=0' \
|
|
>/dev/null 2>&1 ||
|
|
MSYS2_ARG_CONV_EXCL='--entry-hex=' \
|
|
"$FIXTURE" "$TEST_ROOT/invalid-hex.zupt" '--entry-hex=GG' \
|
|
>/dev/null 2>&1; then
|
|
fail 'archive path fixture rejects malformed hex input'
|
|
else
|
|
pass 'archive path fixture rejects malformed hex input'
|
|
fi
|
|
|
|
expect_display_unsafe_hex_path_rejected \
|
|
'raw C1 archive path is rejected without terminal injection' \
|
|
raw-c1 736166659b33316d2e747874 9b
|
|
expect_display_unsafe_hex_path_rejected \
|
|
'UTF-8 C1 archive path is rejected without terminal injection' \
|
|
utf8-c1 73616665c29b33316d2e747874 c29b
|
|
expect_display_unsafe_hex_path_rejected \
|
|
'Unicode bidi-control archive path is rejected without display spoofing' \
|
|
bidi 73616665e280ae6578652e747874 e280ae
|
|
expect_display_unsafe_hex_path_rejected \
|
|
'invalid UTF-8 archive path is rejected without raw display' \
|
|
invalid-utf8 73616665c0af2e747874 c0af
|
|
|
|
make_fixture "$TEST_ROOT/leaf.zupt" 'innocent.txt'
|
|
printf '%s\n' DO_NOT_OVERWRITE > "$TEST_ROOT/sentinel"
|
|
mkdir "$TEST_ROOT/leaf-out"
|
|
if ln -s "$TEST_ROOT/sentinel" "$TEST_ROOT/leaf-out/innocent.txt" \
|
|
2>/dev/null && [[ -L $TEST_ROOT/leaf-out/innocent.txt ]]; then
|
|
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/leaf-out" "$TEST_ROOT/leaf.zupt" \
|
|
> "$TEST_ROOT/leaf.log" 2>&1 &&
|
|
[[ $(<"$TEST_ROOT/sentinel") == DO_NOT_OVERWRITE ]]; then
|
|
pass 'leaf symlink is refused without changing its target'
|
|
else
|
|
fail 'leaf symlink is refused without changing its target'
|
|
fi
|
|
else
|
|
skip 'leaf symlink test is unsupported by this runner'
|
|
fi
|
|
|
|
mkdir "$TEST_ROOT/regular-out"
|
|
printf '%s\n' EXISTING > "$TEST_ROOT/regular-out/innocent.txt"
|
|
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/regular-out" "$TEST_ROOT/leaf.zupt" \
|
|
> "$TEST_ROOT/regular.log" 2>&1 &&
|
|
[[ $(<"$TEST_ROOT/regular-out/innocent.txt") == EXISTING ]]; then
|
|
pass 'existing regular file is never overwritten'
|
|
else
|
|
fail 'existing regular file is never overwritten'
|
|
fi
|
|
|
|
mkdir "$TEST_ROOT/hardlink-out"
|
|
printf '%s\n' HARDLINK_SENTINEL > "$TEST_ROOT/hardlink-target"
|
|
if ln "$TEST_ROOT/hardlink-target" "$TEST_ROOT/hardlink-out/innocent.txt" 2>/dev/null; then
|
|
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/hardlink-out" "$TEST_ROOT/leaf.zupt" \
|
|
> "$TEST_ROOT/hardlink.log" 2>&1 &&
|
|
[[ $(<"$TEST_ROOT/hardlink-target") == HARDLINK_SENTINEL ]]; then
|
|
pass 'existing hardlink is never overwritten'
|
|
else
|
|
fail 'existing hardlink is never overwritten'
|
|
fi
|
|
else
|
|
skip 'hardlink test is unsupported by the temporary filesystem'
|
|
fi
|
|
|
|
make_fixture "$TEST_ROOT/parent.zupt" 'nested/file.txt'
|
|
mkdir "$TEST_ROOT/parent-out" "$TEST_ROOT/parent-outside"
|
|
if ln -s "$TEST_ROOT/parent-outside" "$TEST_ROOT/parent-out/nested" \
|
|
2>/dev/null && [[ -L $TEST_ROOT/parent-out/nested ]]; then
|
|
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/parent-out" "$TEST_ROOT/parent.zupt" \
|
|
> "$TEST_ROOT/parent.log" 2>&1 &&
|
|
[[ -z $(find "$TEST_ROOT/parent-outside" -mindepth 1 -print -quit) ]]; then
|
|
pass 'intermediate symlink cannot redirect extraction or directory creation'
|
|
else
|
|
fail 'intermediate symlink cannot redirect extraction or directory creation'
|
|
fi
|
|
else
|
|
skip 'intermediate symlink test is unsupported by this runner'
|
|
fi
|
|
|
|
mkdir "$TEST_ROOT/root-outside"
|
|
if ln -s "$TEST_ROOT/root-outside" "$TEST_ROOT/root-link" 2>/dev/null &&
|
|
[[ -L $TEST_ROOT/root-link ]]; then
|
|
if ((WINDOWS_NATIVE)); then
|
|
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/root-link/child" \
|
|
"$TEST_ROOT/leaf.zupt" > "$TEST_ROOT/root-link.log" 2>&1 &&
|
|
[[ -z $(find "$TEST_ROOT/root-outside" -mindepth 1 -print -quit) ]]; then
|
|
pass 'Windows rejects a reparse-point output-root ancestor'
|
|
else
|
|
fail 'Windows rejects a reparse-point output-root ancestor'
|
|
fi
|
|
elif "$ZUPT_BIN" extract -o "$TEST_ROOT/root-link/child" \
|
|
"$TEST_ROOT/leaf.zupt" > "$TEST_ROOT/root-link.log" 2>&1 &&
|
|
[[ $(<"$TEST_ROOT/root-outside/child/innocent.txt") == 'fixture content' ]]; then
|
|
pass 'user-selected POSIX output-root symlink is resolved once'
|
|
else
|
|
fail 'user-selected POSIX output-root symlink is resolved once'
|
|
fi
|
|
else
|
|
skip 'output-root symlink test is unsupported by this runner'
|
|
fi
|
|
|
|
make_fixture "$TEST_ROOT/backslash.zupt" 'back\slash.txt'
|
|
mkdir "$TEST_ROOT/backslash-out"
|
|
if "$ZUPT_BIN" extract -o "$TEST_ROOT/backslash-out" "$TEST_ROOT/backslash.zupt" \
|
|
> "$TEST_ROOT/backslash.log" 2>&1 &&
|
|
[[ -f $TEST_ROOT/backslash-out/back/slash.txt ]]; then
|
|
pass 'backslash separators are normalized within the extraction root'
|
|
else
|
|
fail 'backslash separators are normalized within the extraction root'
|
|
fi
|
|
|
|
legitimate_entry_hex=73616665206469722f61c3a7c3a36f2e747874
|
|
MSYS2_ARG_CONV_EXCL='--entry-hex=' \
|
|
"$FIXTURE" "$TEST_ROOT/legitimate.zupt" \
|
|
"--entry-hex=$legitimate_entry_hex"
|
|
mkdir "$TEST_ROOT/legitimate-out"
|
|
if file_contains_hex_bytes "$TEST_ROOT/legitimate.zupt" \
|
|
"$legitimate_entry_hex" &&
|
|
"$ZUPT_BIN" extract -o "$TEST_ROOT/legitimate-out" \
|
|
"$TEST_ROOT/legitimate.zupt" > "$TEST_ROOT/legitimate.log" 2>&1 &&
|
|
python3 - "$TEST_ROOT/legitimate-out" "$legitimate_entry_hex" <<'PY'
|
|
import pathlib
|
|
import sys
|
|
|
|
# All process arguments are ASCII. Decode the exact UTF-8 archive bytes here
|
|
# so the native MinGW fixture's narrow-argv transcoding cannot affect the test.
|
|
relative_path = bytes.fromhex(sys.argv[2]).decode("utf-8")
|
|
extracted = pathlib.Path(sys.argv[1]).joinpath(*relative_path.split("/"))
|
|
raise SystemExit(0 if extracted.read_bytes() == b"fixture content\n" else 1)
|
|
PY
|
|
then
|
|
pass 'safe nested UTF-8 path extracts normally'
|
|
else
|
|
fail 'safe nested UTF-8 path extracts normally'
|
|
fi
|
|
|
|
mkdir -p "$TEST_ROOT/relative-root/work"
|
|
if (cd "$TEST_ROOT/relative-root/work" &&
|
|
"$ZUPT_BIN" extract -o ../restore "$TEST_ROOT/leaf.zupt" \
|
|
> "$TEST_ROOT/relative-root.log" 2>&1) &&
|
|
[[ -f $TEST_ROOT/relative-root/restore/innocent.txt ]]; then
|
|
pass 'user-selected output root may contain a relative .. component'
|
|
else
|
|
fail 'user-selected output root may contain a relative .. component'
|
|
fi
|
|
|
|
cp "$TEST_ROOT/leaf.zupt" "$TEST_ROOT/corrupt.zupt"
|
|
python3 - "$TEST_ROOT/corrupt.zupt" <<'PY'
|
|
import pathlib
|
|
import sys
|
|
|
|
path = pathlib.Path(sys.argv[1])
|
|
data = bytearray(path.read_bytes())
|
|
# Header (64) + data-block fixed/varint header (17): first payload byte.
|
|
data[81] ^= 0x01
|
|
path.write_bytes(data)
|
|
PY
|
|
mkdir "$TEST_ROOT/corrupt-out"
|
|
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/corrupt-out" "$TEST_ROOT/corrupt.zupt" \
|
|
> "$TEST_ROOT/corrupt.log" 2>&1 &&
|
|
[[ -z $(find "$TEST_ROOT/corrupt-out" -mindepth 1 -print -quit) ]]; then
|
|
pass 'corrupt payload leaves neither a final nor temporary output file'
|
|
else
|
|
fail 'corrupt payload leaves neither a final nor temporary output file'
|
|
fi
|
|
|
|
printf '\n Path-confinement regression: %d PASS, %d FAIL, %d SKIP\n' \
|
|
"$PASS" "$FAIL" "$SKIP"
|
|
((FAIL == 0))
|