release: restore ZUPT and harden source-only 5.2.2
This commit is contained in:
parent
74e393ba3e
commit
ff99770bd0
205 changed files with 19627 additions and 13215 deletions
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue