#!/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 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 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" # ────────────────────────────────────────────────────────────── 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