Files
file-upload/security_test.sh
T
2026-08-28 10:54:08 +08:00

415 lines
22 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
# ──────────────────────────────────────────────────────────────
section "N. 批量下载 (POST /download-zip)"
# ──────────────────────────────────────────────────────────────
echo "batch-1" > "$TMPDIR/batch1.txt"
echo "batch-2" > "$TMPDIR/batch2.txt"
upload "$TMPDIR/batch1.txt" "batch1.txt" > /dev/null
upload "$TMPDIR/batch2.txt" "batch2.txt" > /dev/null
zipfile="$TMPDIR/batch.zip"
hdrs=$(curl -s -D - -o "$zipfile" -X POST -H "Content-Type: application/json" \
-d '{"files":["batch1.txt","batch2.txt"]}' "$BASE/download-zip")
status=$(echo "$hdrs" | tr -d '\r' | awk 'NR==1{print $2}')
ctype=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^[Cc]ontent-[Tt]ype/ {print $2}')
if [[ "$status" == "200" ]] && echo "$ctype" | grep -qi 'application/zip'; then
if unzip -l "$zipfile" 2>/dev/null | grep -qE 'batch1\.txt|batch2\.txt'; then
ok "POST /download-zip -> 200 application/zip with both files"
else
ko "zip missing files" "unzip -l: $(unzip -l "$zipfile" 2>&1)"
fi
else
ko "batch download baseline" "status=$status ctype=$ctype"
fi
# 非法文件名: ".." 单独出现 -> 400; "../main.go" 清洗后变 "main.go" 不存在 -> 200+miss
status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" \
-d '{"files":[".."]}' "$BASE/download-zip")
[[ "$status" == "400" ]] && ok "\"..\" -> 400" || ko "explicit dotdot" "got $status"
status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" \
-d '{"files":["."]}' "$BASE/download-zip")
[[ "$status" == "400" ]] && ok "\".\" -> 400" || ko "explicit dot" "got $status"
status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" \
-d '{"files":["../main.go"]}' "$BASE/download-zip")
[[ "$status" == "200" || "$status" == "404" ]] && ok "../main.go sanitized, miss handled -> $status" || ko "sanitize+miss" "got $status"
# 不存在的文件: zip 仍生成, 含真实文件, 不含不存在的
zipfile2="$TMPDIR/batch2.zip"
status=$(curl -s -o "$zipfile2" -w "%{http_code}" -X POST -H "Content-Type: application/json" \
-d '{"files":["batch1.txt","does-not-exist-12345.bin"]}' "$BASE/download-zip")
if [[ "$status" == "200" ]]; then
listing=$(unzip -l "$zipfile2" 2>/dev/null || true)
if echo "$listing" | grep -q 'batch1.txt' && ! echo "$listing" | grep -q 'does-not-exist'; then
ok "missing file skipped, real file still packed"
else
ko "missing file handling" "unzip -l: $listing"
fi
else
ko "missing file request" "got $status"
fi
# 超量
files=$(python3 -c 'import json; print(json.dumps({"files":["x"]*101}))')
status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" \
-d "$files" "$BASE/download-zip")
[[ "$status" == "400" ]] && ok "101 files -> 400" || ko "101 files" "got $status"
# Query 形式
status=$(curl -s -o "$TMPDIR/batch_q.zip" -w "%{http_code}" -X POST \
"$BASE/download-zip?files=batch1.txt&files=batch2.txt")
[[ "$status" == "200" ]] && ok "POST /download-zip?files=... -> 200" || ko "query form" "got $status"
# 空 files
status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" \
-d '{"files":[]}' "$BASE/download-zip")
[[ "$status" == "400" ]] && ok "empty files array -> 400" || ko "empty files" "got $status"
# 清理
curl -s -X DELETE "$BASE/files/batch1.txt" > /dev/null
curl -s -X DELETE "$BASE/files/batch2.txt" > /dev/null
rm -f "$TMPDIR/batch.zip" "$TMPDIR/batch2.zip" "$TMPDIR/batch_q.zip"
# ──────────────────────────────────────────────────────────────
section "O. 批量删除 (POST /files-delete)"
# ──────────────────────────────────────────────────────────────
echo "bdel-1" > "$TMPDIR/bdel1.txt"
echo "bdel-2" > "$TMPDIR/bdel2.txt"
upload "$TMPDIR/bdel1.txt" "bdel1.txt" > /dev/null
upload "$TMPDIR/bdel2.txt" "bdel2.txt" > /dev/null
# 删除其一 + 一个不存在: 200, miss 含不存在项, 目标文件确已被删
resp=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"files":["bdel1.txt","does-not-exist-bdel.bin"]}' "$BASE/files-delete")
if echo "$resp" | grep -q '"deleted":1' && echo "$resp" | grep -q 'does-not-exist-bdel.bin'; then
ok "POST /files-delete (1 real + 1 miss) -> 200 deleted=1 miss=[...]"
else
ko "batch delete baseline" "resp=$resp"
fi
status=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/download/bdel1.txt")
[[ "$status" == "404" ]] && ok "deleted file no longer downloadable -> 404" || ko "deleted file" "got $status"
# 非法文件名 -> 400
status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" \
-d '{"files":[".."]}' "$BASE/files-delete")
[[ "$status" == "400" ]] && ok "invalid filename \"..\" -> 400" || ko "invalid filename" "got $status"
# 超 100 -> 400
files=$(python3 -c 'import json; print(json.dumps({"files":["x"]*101}))')
status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" \
-d "$files" "$BASE/files-delete")
[[ "$status" == "400" ]] && ok "101 files -> 400" || ko "101 files" "got $status"
# 空 body -> 400
status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" \
-d '{"files":[]}' "$BASE/files-delete")
[[ "$status" == "400" ]] && ok "empty files array -> 400" || ko "empty files" "got $status"
# 清理
curl -s -X DELETE "$BASE/files/bdel2.txt" > /dev/null
# ──────────────────────────────────────────────────────────────
section "P. 静态资源 (/static StaticFS)"
# ──────────────────────────────────────────────────────────────
html=$(curl -s "$BASE/")
if echo "$html" | grep -q 'href="/static/app.css"'; then
ok "GET / contains <link href=\"/static/app.css\">"
else
ko "GET / link tag" "link tag missing"
fi
for js in app.js helpers.js batch.js; do
code=$(status "$BASE/static/$js")
[[ "$code" == "200" ]] && ok "GET /static/$js -> 200" || ko "GET /static/$js" "got $code"
done
hdrs=$(curl -s -D - -o /dev/null "$BASE/static/app.css")
ctype=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^[Cc]ontent-[Tt]ype/ {print $2}')
cc=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^[Cc]ache-[Cc]ontrol/ {print $2}')
if echo "$ctype" | grep -qi 'text/css'; then
ok "GET /static/app.css -> Content-Type: text/css"
else
ko "app.css content-type" "got [$ctype]"
fi
if echo "$cc" | grep -qi 'no-cache'; then
ok "GET /static/app.css -> Cache-Control: no-cache"
else
ko "app.css cache-control" "got [$cc]"
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