This commit is contained in:
tao.chen
2026-08-27 12:41:28 +08:00
commit c8e4996c34
7 changed files with 2010 additions and 0 deletions
+279
View File
@@ -0,0 +1,279 @@
#!/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