release: restore ZUPT and harden source-only 5.2.2

This commit is contained in:
Cristian Cezar Moisés 2026-08-31 14:14:36 -03:00
commit ff99770bd0
205 changed files with 19627 additions and 13215 deletions

View file

@ -1,156 +1,264 @@
#!/bin/bash
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Path traversal / Zip Slip regression tests.
#
# Verifies that the v2.2.2 audit fixes for CVE-pattern path traversal
# (Snyk Zip Slip 2018) and symlink-following on extract are working.
#
# Tests construct malicious archives in two ways:
# (A) compress with a relative path then post-mutate the index (manual fuzz)
# (B) try to extract into a directory containing a symlink with the same
# name as an archive entry — should be refused due to O_NOFOLLOW.
# Extraction confinement and atomic-output regression tests.
ZUPT_BIN="$(realpath ./zupt)"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"
set -Eeuo pipefail
PASS=0; FAIL=0
chk() {
if [ $? -eq 0 ]; then echo "$1"; PASS=$((PASS+1))
else echo "$1"; FAIL=$((FAIL+1)); fi
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
}
# ─── Property 1: archive with ".." entry must not extract above target ──
# Strategy: compress an innocent file, then patch the archive's index to
# replace the path with "../../escaped.txt". Extract into a subdir;
# verify the file appears nowhere outside the subdir.
echo " [P1. Zip Slip — relative path traversal blocked]"
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
}
mkdir input output_safe
echo "secret content" > input/innocent.txt
"$ZUPT_BIN" c slip.zupt input/innocent.txt > /dev/null 2>&1
cd "$TEST_ROOT"
# Patch the archive: replace "input/innocent.txt" path string with
# "../escape.txt" in the index. We use a python helper because the index
# is varint-prefixed and we need to keep length consistent.
python3 << 'PYEOF'
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
expect_display_unsafe_path_rejected() {
local label=$1 name=$2 entry=$3
local archive=$TEST_ROOT/$name.zupt log=$TEST_ROOT/$name.log status
MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$archive" "--entry=$entry"
set +e
"$ZUPT_BIN" list "$archive" > "$log" 2>&1
status=$?
set -e
if ((status != 0)) && ! LC_ALL=C grep -Fq -- "$entry" "$log"; then
pass "$label"
else
fail "$label"
fi
}
expect_display_unsafe_path_rejected \
'raw C1 archive path is rejected without terminal injection' \
raw-c1 $'safe\23331m.txt'
expect_display_unsafe_path_rejected \
'UTF-8 C1 archive path is rejected without terminal injection' \
utf8-c1 $'safe\302\23331m.txt'
expect_display_unsafe_path_rejected \
'Unicode bidi-control archive path is rejected without display spoofing' \
bidi $'safe\342\200\256exe.txt'
expect_display_unsafe_path_rejected \
'invalid UTF-8 archive path is rejected without raw display' \
invalid-utf8 $'safe\300\257.txt'
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
make_fixture "$TEST_ROOT/legitimate.zupt" 'safe dir/ação.txt'
mkdir "$TEST_ROOT/legitimate-out"
if "$ZUPT_BIN" extract -o "$TEST_ROOT/legitimate-out" \
"$TEST_ROOT/legitimate.zupt" > "$TEST_ROOT/legitimate.log" 2>&1 &&
[[ $(<"$TEST_ROOT/legitimate-out/safe dir/ação.txt") == 'fixture content' ]]; 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
data = bytearray(open('slip.zupt','rb').read())
target = b'input/innocent.txt'
replacement = b'../escape.txt'
# Pad replacement to same length so varint length prefix stays valid
pad = b'\x00' * (len(target) - len(replacement))
i = data.find(target)
if i < 0:
print("ERROR: pattern not in archive")
sys.exit(1)
# Replace the bytes — note this will fail validation below, which is OK,
# we want to see if extract REJECTS the malformed path.
data[i:i+len(target)] = replacement + pad
open('slip_patched.zupt','wb').write(bytes(data))
PYEOF
# Try to extract — even if the patched archive is corrupt, we want to
# verify that NO file appears at "../escape.txt" relative to output_safe.
cd output_safe
"$ZUPT_BIN" x ../slip_patched.zupt > /dev/null 2>&1
cd ..
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
# The key invariant: nothing escaped to TMPDIR (parent of output_safe)
[ ! -f "$TMPDIR/escape.txt" ] && [ ! -f escape.txt ]
chk "No escape via patched ../escape.txt path"
# ─── Property 2: archive with absolute path must not write to that path ──
echo " [P2. Absolute path entries blocked]"
# Construct an archive entry with absolute "/tmp/owned.txt" via patching
echo "innocent" > input2.txt
"$ZUPT_BIN" c abs.zupt input2.txt > /dev/null 2>&1
python3 << 'PYEOF'
data = bytearray(open('abs.zupt','rb').read())
target = b'input2.txt'
# Replace with absolute path of equal length
replacement = b'/tmp/owned' # 10 chars vs 10 chars
i = data.find(target)
if i >= 0:
data[i:i+len(target)] = replacement
open('abs_patched.zupt','wb').write(bytes(data))
PYEOF
mkdir abs_extract
cd abs_extract
"$ZUPT_BIN" x ../abs_patched.zupt > /dev/null 2>&1
cd ..
[ ! -f /tmp/owned ]
chk "Absolute /tmp/owned path rejected"
# ─── Property 3: symlink at output target is not followed ──────────────
# Pre-place a symlink in output dir pointing to a sentinel file.
# Extract an archive with the same entry name; verify the sentinel is
# unchanged (i.e. extract refused to follow the symlink).
echo " [P3. Symlink at extract target not followed]"
echo "DO_NOT_OVERWRITE" > sentinel.txt
mkdir symlink_extract
ln -s "$(pwd)/sentinel.txt" symlink_extract/innocent.txt
# Build a fresh non-patched archive with "innocent.txt"
mkdir input3 && echo "evil overwrite content" > input3/innocent.txt
"$ZUPT_BIN" c clean.zupt input3/innocent.txt > /dev/null 2>&1
# Mutate path "input3/innocent.txt" -> "innocent.txt" so it lands at the symlink
python3 << 'PYEOF'
data = bytearray(open('clean.zupt','rb').read())
target = b'input3/innocent.txt'
replacement = b'innocent.txt' + (b'\x00' * (len(target) - len(b'innocent.txt')))
i = data.find(target)
if i >= 0:
data[i:i+len(target)] = replacement
open('clean_patched.zupt','wb').write(bytes(data))
PYEOF
cd symlink_extract
"$ZUPT_BIN" x ../clean_patched.zupt > /dev/null 2>&1
cd ..
# Sentinel must be unchanged — symlink follow would have overwritten it
content=$(cat sentinel.txt)
[ "$content" = "DO_NOT_OVERWRITE" ]
chk "Sentinel via symlink not overwritten"
# ─── Property 4: legitimate paths still extract correctly ─────────────
echo " [P4. Legitimate (safe) paths still extract]"
mkdir legit_input
echo "ok content" > legit_input/normal.txt
"$ZUPT_BIN" c legit.zupt legit_input/normal.txt > /dev/null 2>&1
mkdir legit_extract && cd legit_extract
"$ZUPT_BIN" x ../legit.zupt > /dev/null 2>&1
cd ..
[ -f legit_extract/legit_input/normal.txt ] && \
[ "$(cat legit_extract/legit_input/normal.txt)" = "ok content" ]
chk "Normal extraction still works"
# ─── Property 5: deep path (allowed) but parent dir is created ─────────
echo " [P5. Multi-component safe paths still work]"
mkdir deep && mkdir deep/sub && mkdir deep/sub/sub2
echo "deep" > deep/sub/sub2/file.txt
"$ZUPT_BIN" c deep.zupt deep/sub/sub2/file.txt > /dev/null 2>&1
mkdir deep_extract && cd deep_extract
"$ZUPT_BIN" x ../deep.zupt > /dev/null 2>&1
cd ..
[ -f deep_extract/deep/sub/sub2/file.txt ]
chk "Deep nested path extracted"
echo
echo " ───────────────────────────────────────"
echo " Path-traversal regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ $FAIL -eq 0 ]
printf '\n Path-confinement regression: %d PASS, %d FAIL, %d SKIP\n' \
"$PASS" "$FAIL" "$SKIP"
((FAIL == 0))