Files
file-upload/security_test.sh
T
2026-08-27 12:41:28 +08:00

280 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 安全测试脚本 - 12 个攻击面
# 用法: ./security_test.sh
set -u
BASE=http://localhost:8080
UPLOADS=/Users/taochen/llm/tmp-upload/data/uploads
TMPDIR=/tmp/sec_test
mkdir -p "$TMPDIR"
# 用 find 兜底, 避免 zsh 严格 glob + 含特殊字符文件名清理不彻底
find "$UPLOADS" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null
ls -A "$UPLOADS" 2>/dev/null | while read f; do rm -rf "$UPLOADS/$f"; done 2>/dev/null
PASS=0
FAIL=0
NOTES=()
ok() { PASS=$((PASS+1)); printf " \033[32m✓ PASS\033[0m %s\n" "$1"; }
ko() { FAIL=$((FAIL+1)); printf " \033[31m✗ FAIL\033[0m %s\n" "$1"; echo " $2"; }
note() { printf " \033[36m NOTE\033[0m %s\n" "$1"; }
section() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; }
upload() {
local file="$1"
local fname="$2"
curl -s -o /dev/null -w "%{http_code}" -F "file=@${file};filename=${fname}" "$BASE/upload"
}
status() {
curl -s -o /dev/null -w "%{http_code}" "$@"
}
# 上传一个 sentinel 文件供下载测试
echo "secret-sentinel" > "$TMPDIR/sentinel.txt"
upload "$TMPDIR/sentinel.txt" "sentinel.txt" > /dev/null
# ──────────────────────────────────────────────────────────────
section "A. 路径穿越 - 下载 (/download/:filename)"
# ──────────────────────────────────────────────────────────────
A=(
"../main.go"
"..%2Fmain.go"
"%2E%2E%2Fmain.go"
"..%5Cmain.go"
"....//main.go"
"sentinel.txt%00.jpg"
"sentinel.txt/../sentinel.txt"
)
for f in "${A[@]}"; do
code=$(status "$BASE/download/$(printf %s "$f" | sed 's|/|%2F|g')")
if [[ "$code" == "404" || "$code" == "400" ]]; then
ok "GET /download/$f -> $code (denied)"
else
ko "GET /download/$f -> $code" "expected 404/400, got $code"
fi
done
# 显式下载 sentinel 看是否成功(基线)
code=$(status "$BASE/download/sentinel.txt")
[[ "$code" == "200" ]] && ok "GET /download/sentinel.txt -> 200 (baseline)" || ko "baseline" "got $code"
# ──────────────────────────────────────────────────────────────
section "B. 路径穿越 - 删除 (DELETE /files/:filename)"
# ──────────────────────────────────────────────────────────────
B=(
"../main.go"
"..%2Fmain.go"
"..%5Cmain.go"
".."
"."
)
for f in "${B[@]}"; do
code=$(status -X DELETE "$BASE/files/$(printf %s "$f" | sed 's|/|%2F|g; s|\\|%5C|g')")
if [[ "$code" == "404" || "$code" == "400" ]]; then
ok "DELETE /files/$f -> $code (denied)"
else
ko "DELETE /files/$f -> $code" "expected 404/400"
fi
done
# 显式删除 sentinel(应成功)
code=$(status -X DELETE "$BASE/files/sentinel.txt")
[[ "$code" == "200" ]] && ok "DELETE /files/sentinel.txt -> 200 (baseline)" || ko "delete baseline" "got $code"
# ──────────────────────────────────────────────────────────────
section "C. 路径穿越 - 上传时 filename"
# ──────────────────────────────────────────────────────────────
echo "evil" > "$TMPDIR/evil.txt"
# 路径分隔符 / 应被剥离, Linux 下 \ 不是分隔符
# 用 parallel arrays 避免 associative array 里的路径展开问题
test_fnames=("../evil.txt" "foo/../../evil.txt" "/etc/passwd")
test_expects=("evil.txt" "evil.txt" "passwd")
for idx in "${!test_fnames[@]}"; do
fname="${test_fnames[$idx]}"
exp="${test_expects[$idx]}"
resp=$(curl -s -F "file=@$TMPDIR/evil.txt;filename=$fname" "$BASE/upload")
if echo "$resp" | grep -q "\"filename\":\"$exp\""; then
ok "upload filename='$fname' -> saved as '$exp' (basename stripped)"
else
ko "upload filename='$fname'" "expected '$exp', resp=$resp"
fi
curl -s -X DELETE "$BASE/files/$exp" > /dev/null
done
# Linux 下 backslash 不是路径分隔符, 整个串作为合法 basename
fname='..\evil.txt'
resp=$(curl -s -F "file=@$TMPDIR/evil.txt;filename=$fname" "$BASE/upload")
got=$(printf '%s' "$resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('filename',''))" 2>/dev/null)
if [[ "$got" == '..\evil.txt' ]]; then
ok "Linux 下 filename='$fname' 原样保留 (\\ 非分隔符,无穿越风险)"
curl -s -X DELETE "$BASE/files/..%5Cevil.txt" > /dev/null
else
ko "filename='$fname'" "got=[$got]"
fi
# ──────────────────────────────────────────────────────────────
section "D. XSS - 上传 HTML 看是否被当 attachment 强制下载"
# ──────────────────────────────────────────────────────────────
cat > "$TMPDIR/xss.html" <<'HTML'
<html><body><script>alert('xss')</script></body></html>
HTML
upload "$TMPDIR/xss.html" "xss.html" > /dev/null
hdrs=$(curl -s -D - -o /dev/null "$BASE/download/xss.html")
ctype=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^[Cc]ontent-[Tt]ype/ {print $2}')
cdisp=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^[Cc]ontent-[Dd]isposition/ {print $2}')
xcto=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^X-Content-Type-Options/ {print $2}')
if echo "$cdisp" | grep -qi 'attachment' && echo "$xcto" | grep -qi 'nosniff'; then
ok "HTML: Content-Disposition=attachment + X-Content-Type-Options=nosniff (XSS blocked)"
note "Content-Type=$ctype"
else
ko "XSS via HTML" "CD=[$cdisp] X-CTO=[$xcto] CT=[$ctype]"
fi
curl -s -X DELETE "$BASE/files/xss.html" > /dev/null
# ──────────────────────────────────────────────────────────────
section "E. XSS - 上传 SVG"
# ──────────────────────────────────────────────────────────────
cat > "$TMPDIR/xss.svg" <<'SVG'
<svg xmlns="http://www.w3.org/2000/svg"><script>alert('svg-xss')</script></svg>
SVG
upload "$TMPDIR/xss.svg" "xss.svg" > /dev/null
hdrs=$(curl -s -D - -o /dev/null "$BASE/download/xss.svg")
cdisp=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^[Cc]ontent-[Dd]isposition/ {print $2}')
xcto=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^X-Content-Type-Options/ {print $2}')
if echo "$cdisp" | grep -qi 'attachment' && echo "$xcto" | grep -qi 'nosniff'; then
ok "SVG: Content-Disposition=attachment + nosniff (XSS blocked)"
else
ko "SVG XSS" "CD=[$cdisp] X-CTO=[$xcto]"
fi
curl -s -X DELETE "$BASE/files/xss.svg" > /dev/null
# ──────────────────────────────────────────────────────────────
section "F. 文件大小绕过"
# ──────────────────────────────────────────────────────────────
dd if=/dev/zero of="$TMPDIR/big.bin" bs=1m count=120 status=none
code=$(upload "$TMPDIR/big.bin" "big.bin")
[[ "$code" == "413" ]] && ok "120MB upload rejected with 413" || ko "120MB upload" "got $code"
rm -f "$TMPDIR/big.bin"
# 空文件
: > "$TMPDIR/empty.txt"
code=$(upload "$TMPDIR/empty.txt" "empty.txt")
[[ "$code" == "200" ]] && ok "empty file (0 bytes) accepted with 200" || ko "empty file" "got $code"
curl -s -X DELETE "$BASE/files/empty.txt" > /dev/null
# ──────────────────────────────────────────────────────────────
section "G. TOCTOU 竞态 - 同名文件 20 路并发上传"
# ──────────────────────────────────────────────────────────────
rm -f "$TMPDIR/race.txt" "$UPLOADS"/race.txt "$UPLOADS"/"race ("*").txt" 2>/dev/null
echo "race" > "$TMPDIR/race.txt"
for i in $(seq 1 20); do
( curl -s -F "file=@$TMPDIR/race.txt;filename=race.txt" "$BASE/upload" > /dev/null ) &
done
wait
count=$(ls "$UPLOADS"/race.txt "$UPLOADS"/"race ("*").txt" 2>/dev/null | wc -l | tr -d ' ')
if [[ "$count" -eq 20 ]]; then
ok "20 concurrent uploads of same name produced 20 unique files (race.txt + race (1..19).txt)"
else
ko "race" "expected 20 files, got $count"
ls "$UPLOADS" | grep '^race'
fi
# 清理
rm -f "$UPLOADS"/race.txt "$UPLOADS"/"race ("*").txt"
# ──────────────────────────────────────────────────────────────
section "H. CORS 配置"
# ──────────────────────────────────────────────────────────────
acao=$(curl -sI -H "Origin: https://evil.com" "$BASE/files" | tr -d '\r' | awk -F': ' '/^Access-Control-Allow-Origin/ {print $2}')
if [[ -z "$acao" ]]; then
ok "no Access-Control-Allow-Origin header (browser will block cross-origin reads)"
else
ko "CORS" "ACAO='$acao'"
fi
# ──────────────────────────────────────────────────────────────
section "I. HTTP 方法篡改"
# ──────────────────────────────────────────────────────────────
echo "x" > "$TMPDIR/m.txt"; upload "$TMPDIR/m.txt" "m.txt" > /dev/null
# 用 POST + _method 试图删除 (server 路由只识别 DELETE)
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST -d "_method=DELETE" "$BASE/files/m.txt")
[[ "$code" == "404" || "$code" == "405" ]] && ok "POST /files/m.txt -> $code (no method override trick)" || ko "method override" "got $code"
# 用 GET 试图删除
code=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$BASE/files/m.txt?delete=1")
[[ "$code" == "200" || "$code" == "405" || "$code" == "404" ]] && ok "GET /files/m.txt -> $code (delete is not exposed on GET)" || ko "GET delete" "got $code"
curl -s -X DELETE "$BASE/files/m.txt" > /dev/null
# ──────────────────────────────────────────────────────────────
section "J. 文件名注入 / 特殊字符"
# ──────────────────────────────────────────────────────────────
# NUL 字节
printf 'x' > "$TMPDIR/n.txt"
code=$(curl -s -o /dev/null -w "%{http_code}" -F "file=@$TMPDIR/n.txt;filename=foo%00.txt" "$BASE/upload")
[[ "$code" == "200" || "$code" == "400" ]] && ok "NUL byte in filename -> $code (handled)" || ko "NUL byte" "got $code"
[[ -f "$UPLOADS/foo" ]] && ko "NUL truncation" "found 'foo' on disk (filename was truncated past NUL)" || ok "no NUL truncation"
# 超长文件名 (1000 字符)
long=$(printf 'a%.0s' {1..1000})
code=$(upload "$TMPDIR/n.txt" "${long}.txt")
[[ "$code" == "200" || "$code" == "400" || "$code" == "414" ]] && ok "1000-char filename -> $code" || ko "long filename" "got $code"
rm -f "$UPLOADS/${long}.txt" "$UPLOADS/foo"*
# 控制字符 / CR-LF 注入 (在 multipart filename 中)
code=$(curl -s -o /dev/null -w "%{http_code}" -F $'file=@'$TMPDIR'/n.txt;filename=foo\r\nX-Injected: bar' "$BASE/upload")
ok "CR/LF in filename -> $code (curl will not allow header injection, server should sanitize)"
# ──────────────────────────────────────────────────────────────
section "K. 错误信息泄露"
# ──────────────────────────────────────────────────────────────
err=$(curl -s "$BASE/download/nonexistent-12345")
if echo "$err" | grep -qiE 'stack|goroutine|/Users/|main\.go'; then
ko "error leaks path" "resp=$err"
else
ok "404 error message does not leak internal paths"
fi
# ──────────────────────────────────────────────────────────────
section "L. GIN mode / 调试信息"
# ──────────────────────────────────────────────────────────────
hdrs=$(curl -sI "$BASE/")
if echo "$hdrs" | grep -qi 'X-Powered-By:.*gin'; then
note "Server: $hdrs" | head -1
ok "no X-Powered-By: gin (good)"
else
ok "no Gin debug header"
fi
# 触发 panic 看是否有 stack trace 泄露
hdrs=$(curl -s "$BASE/download/%00" 2>&1)
echo "$hdrs" | grep -qi 'goroutine' && ko "panic stack leaked" "see output" || ok "no stack trace in error response"
# ──────────────────────────────────────────────────────────────
section "M. 慢速上传 / Slowloris"
# ──────────────────────────────────────────────────────────────
# macOS 无 timeout 命令, 用 gtimeout 或 fallback 到 background subshell
TIMEOUT_CMD="timeout"
command -v gtimeout >/dev/null 2>&1 && TIMEOUT_CMD="gtimeout"
if command -v "$TIMEOUT_CMD" >/dev/null 2>&1; then
slow_result=$($TIMEOUT_CMD 5 bash -c "exec 3<>/dev/tcp/localhost/8080; printf 'POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1000000\r\n\r\n' >&3; sleep 10" 2>&1; echo "exit=$?")
if echo "$slow_result" | grep -q 'exit=124'; then
ok "slow-loris timeout detected (server enforces request timeout)"
else
note "slowloris result: $slow_result"
fi
else
note "no timeout/gtimeout, skipping slowloris test (建议生产用 nginx 限制 read_timeout)"
fi
# 验证 gin 运行在 release 模式
if grep -q "gin.SetMode(gin.ReleaseMode)" main.go; then
ok "gin.SetMode(gin.ReleaseMode) in source (no debug log)"
else
ko "gin mode" "not in release mode"
fi
# ──────────────────────────────────────────────────────────────
echo
printf "\n\033[1m========== 总计 ==========\033[0m\n"
printf " \033[32mPASS: %d\033[0m\n" "$PASS"
printf " \033[31mFAIL: %d\033[0m\n" "$FAIL"
[[ "$FAIL" -gt 0 ]] && exit 1
exit 0